@ -26,7 +26,7 @@ import java.util.concurrent.Executors
* Полудуплексный аудио - движок рации ( аналог CallAudioEngine , но с PTT ) .
*
* Потоки :
* - capture - поток : AudioRecord . read ( 960 ) → NativeLib . radioAudioFeed ( PTT либо активный VAD )
* - capture - поток : AudioRecord . read ( 960 ) → NativeLib . radioAudioFeed ( PTT , автоматический детектор либо измеритель )
* - play - поток : NativeLib . radioAudioPull ( C : per - source jitter → декод → микшер ) → AudioTrack . write
*
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
@ -82,15 +82,19 @@ class RadioAudioEngine {
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 постоянно */
private var autoSettings = RadioAutoSettings ( )
private var settingsReady = false
private var settingsRequest = 0L
private var captureRequestedMode = RadioAutoSettings . OFF
@Volatile private var autoMode = RadioAutoSettings . OFF
@Volatile private var levelMonitoring = false
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
val router = CallAudioRouter ( )
private val _talkingFlow = MutableStateFlow ( false )
val talkingFlow : StateFlow < Boolean > = _talkingFlow
/** Фактический TX (PTT/VAD ), привязанный к каналу. Capture публикует, io сбрасывает после join. */
/** Фактический TX (PTT/автоматика ), привязанный к каналу. Capture публикует, io сбрасывает после join. */
private val _transmittingGroup = MutableStateFlow < Long ? > ( null )
val transmittingGroup : StateFlow < Long ? > = _transmittingGroup
@ -99,19 +103,19 @@ class RadioAudioEngine {
Thread ( r , " radio-audio-io " ) . apply { isDaemon = true }
}
/* Таймер окна VAD ; управление ресурсами всегда на io-потоке. */
/* Таймер окна автоматики ; управление ресурсами всегда на io-потоке. */
private val mainHandler = Handler ( Looper . getMainLooper ( ) )
private val releaseTalk = Runnable { finishTalk ( ) }
private val vadTimeout = Runnable {
io . execute {
if ( active && vadWindow . deadlineMs > 0L && ! vadWindow . isAllowed ( SystemClock . elapsedRealtime ( ) ) ) {
LogManager . addLog ( " INFO " , " debug " , " VAD background window expired (15 minutes) group=$audioGroup " )
LogManager . addLog ( " INFO " , " debug " , " Auto background window expired (15 minutes) group=$audioGroup " )
updateCapture ( )
}
}
}
private val _vad ActiveFlow = MutableStateFlow ( false )
val vad ActiveFlow: StateFlow < Boolean > = _vad ActiveFlow
private val _auto ActiveFlow = MutableStateFlow ( false )
val auto ActiveFlow: StateFlow < Boolean > = _auto ActiveFlow
fun isActive ( ) : Boolean = active
@ -166,24 +170,24 @@ class RadioAudioEngine {
io . execute { releaseLocked ( ) }
}
/** Смена настроек сохраняет окно VAD ; явное включение открывает новое. */
fun reconfigure ( restartVad : Boolean = false ) {
/** Смена настроек сохраняет окно автоматики ; явное включение открывает новое. */
fun reconfigure ( restartAuto : Boolean = false ) {
val version = generation
io . execute {
if ( ! active || generation != version || audioTrack == null ) return @execute
stopCapture ( )
loadCaptureSettings ( )
if ( restartVad ) vadWindow . restart ( SystemClock . elapsedRealtime ( ) )
if ( restartAuto ) vadWindow . restart ( SystemClock . elapsedRealtime ( ) )
updateCapture ( )
}
}
/** Видимое приложение сбрасывает фоновое окно VAD и отключает отсчёт. */
/** Видимое приложение сбрасывает фоновое окно автоматики и отключает отсчёт. */
fun setForeground ( foreground : Boolean ) {
val nowMs = SystemClock . elapsedRealtime ( )
io . execute {
vadWindow . setForeground ( foreground , nowMs )
LogManager . addLog ( " DEBUG " , " debug " , " VAD application foreground=$foreground group= $audioGroup " +
LogManager . addLog ( " DEBUG " , " debug " , " Auto application foreground=$foreground group= $audioGroup " +
" backgroundLeftMs= ${vadWindow.timeLeftMs(nowMs)} " )
if ( active ) updateCapture ( )
}
@ -261,7 +265,35 @@ class RadioAudioEngine {
configuredInput = provider . getRadioInputSource ( )
configuredMicSource = provider . getRadioMicSource ( )
configuredStereo = provider . getRadioCaptureStereo ( )
configuredVad = provider . getInt ( " radio.vad_enabled " ) != 0
autoSettings = provider . getRadioAutoSettings ( )
settingsReady = false
val request = ++ settingsRequest
if ( ! autoSettings . valid ( ) || ! NativeLib . radioConfigure ( request , autoSettings ) ) {
LogManager . addLog ( " ERROR " , " radio " , " Cannot apply automatic transmission settings request= $request settings= $autoSettings " )
} else mainHandler . postDelayed ( {
io . execute {
if ( active && request == settingsRequest && ! settingsReady )
LogManager . addLog ( " ERROR " , " radio " , " Automatic transmission settings acknowledgement timeout request= $request group= $audioGroup " )
}
} , 5000 )
}
internal fun onSettingsApplied ( request : Long , success : Boolean ) {
io . execute {
if ( ! active || request != settingsRequest ) return @execute
settingsReady = success
LogManager . addLog ( if ( success ) " INFO " else " ERROR " , " radio " , " Automatic settings applied request= $request success= $success " )
if ( success ) updateCapture ( )
}
}
/** Только видимая вкладка настроек владеет дополнительным захватом для измерителя. */
fun setLevelMonitoring ( enabled : Boolean ) {
levelMonitoring = enabled
io . execute {
LogManager . addLog ( " DEBUG " , " radio " , " Level monitoring= $levelMonitoring active= $active group= $audioGroup " )
if ( active ) updateCapture ( )
}
}
/ * Создать AudioRecord с маршрутизацией входного устройства : встроенный микрофон
@ -371,11 +403,11 @@ class RadioAudioEngine {
if ( ! active || generation != version ) return @execute
if ( intent . action == Intent . ACTION _SCREEN _OFF ) {
vadWindow . close ( )
LogManager . addLog ( " INFO " , " debug " , " VAD screen off: window closed group=$audioGroup " )
LogManager . addLog ( " INFO " , " debug " , " Auto screen off: window closed group=$audioGroup " )
}
if ( intent . action == Intent . ACTION _SCREEN _ON ) {
vadWindow . restart ( SystemClock . elapsedRealtime ( ) )
LogManager . addLog ( " INFO " , " debug " , " VAD screen on: new background window (15 minutes) group=$audioGroup " )
LogManager . addLog ( " INFO " , " debug " , " Auto screen on: new background window (15 minutes) group=$audioGroup " )
}
updateCapture ( )
}
@ -393,31 +425,23 @@ class RadioAudioEngine {
screenInteractive = screenOn ( )
}
/** VAD без ограничения в приложении; после сворачивания — полные 15 минут при горящем экране . */
/** Оба детектора используют одно окно по экрану/фону; измеритель не продлевает это окно . */
private fun updateCapture ( ) {
mainHandler . removeCallbacks ( vadTimeout )
val configured = configuredVad
if ( ! settingsReady ) return
val now = SystemClock . elapsedRealtime ( )
vadWindow . setEnabled ( configured , now )
val wantVad = active && screenInteractive && vadWindow . isAllowed ( now ) && configured
val reason = when {
! active -> " radio_off "
! configured -> " vad_disabled "
! screenInteractive -> " screen_off "
! vadWindow . isAllowed ( now ) -> " window_expired "
else -> " vad_allowed "
}
LogManager . addLog ( " DEBUG " , " debug " , " VAD capture gate group= $audioGroup reason= $reason requested= $configured " +
" screen= $screenInteractive foreground= ${vadWindow.foreground} backgroundLeftMs= ${vadWindow.timeLeftMs(now)} manual= $requestedTalk " +
" capture= $captureRunning nativeVad= $vadMode playback= ${audioTrack != null} " )
if ( wantVad && ! vadWindow . foreground ) mainHandler . postDelayed ( vadTimeout , vadWindow . timeLeftMs ( now ) . coerceAtLeast ( 1L ) )
if ( ! active || ( ! requestedTalk && ! wantVad ) ) {
stopCapture ( )
return
}
if ( audioRecord != null && vadMode != wantVad ) stopCapture ( )
vadWindow . setEnabled ( autoSettings . enabled , now )
val mode = radioCaptureMode ( active , screenInteractive , vadWindow . isAllowed ( now ) , autoSettings , requestedTalk , levelMonitoring )
LogManager . addLog ( " DEBUG " , " radio " , " Auto capture gate group= $audioGroup desired= $mode requested= ${autoSettings.mode} " +
" enabled= ${autoSettings.enabled} screen= $screenInteractive foreground= ${vadWindow.foreground} " +
" backgroundLeftMs= ${vadWindow.timeLeftMs(now)} manual= $requestedTalk monitor= $levelMonitoring capture= $captureRunning " )
if ( mode != null && mode != RadioAutoSettings . OFF && ! vadWindow . foreground )
mainHandler . postDelayed ( vadTimeout , vadWindow . timeLeftMs ( now ) . coerceAtLeast ( 1L ) )
if ( mode == null ) { stopCapture ( ) ; return }
val detectorFailed = mode != RadioAutoSettings . OFF && autoMode == RadioAutoSettings . OFF
if ( audioRecord != null && ( captureRequestedMode != mode || ( detectorFailed && ! requestedTalk && ! levelMonitoring ) ) ) stopCapture ( )
if ( audioTrack == null ) return
if ( audioRecord == null && ! startCapture ( wantVad ) ) {
if ( audioRecord == null && ! startCapture ( mode ) ) {
requestedTalk = false
_talkingFlow . value = false
return
@ -433,23 +457,24 @@ class RadioAudioEngine {
}
}
private fun startCapture ( vad : Boolean ) : Boolean {
private fun startCapture ( mode : Int ) : Boolean {
if ( ! MicrophonePermission . granted ( ) ) {
LogManager . addLog ( " WARN " , " debug " , " VAD /PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup vad= $vad " )
LogManager . addLog ( " WARN " , " debug " , " Auto /PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup mode= $mode " )
return false
}
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 " )
if ( ! NativeLib . radioCaptureStart ( audioGroup , channels , mode ) ) {
LogManager . addLog ( " ERROR " , " RadioAudio " , " native capture start failed group= $audioGroup ch= $channels mode= $mode " )
rec . release ( )
return false
}
vadMode = NativeLib . radioVadMode ( )
if ( vad && ! vadMode ) {
LogManager . addLog ( " ERROR " , " debug " , " VAD unavailable: capture cancelled group= $audioGroup " )
autoMode = NativeLib . radioAutoMode ( )
captureRequestedMode = mode
if ( mode != RadioAutoSettings . OFF && autoMode == RadioAutoSettings . OFF && ! requestedTalk && ! levelMonitoring ) {
LogManager . addLog ( " ERROR " , " debug " , " Automatic transmission unavailable: capture cancelled group= $audioGroup " )
NativeLib . radioCaptureStop ( ) ; rec . release ( )
return false
}
@ -460,13 +485,13 @@ class RadioAudioEngine {
} catch ( e : Exception ) {
LogManager . addLog ( " ERROR " , " RadioAudio " , " capture start failed: ${e.message} " )
NativeLib . radioCaptureStop ( ) ; rec . release ( )
vadMode = false
autoMode = RadioAutoSettings . OFF
return false
}
val recordingNs = SystemClock . elapsedRealtimeNanos ( )
audioRecord = rec
captureRunning = true
_vad ActiveFlow . value = vadMode
_auto ActiveFlow . value = autoMode != RadioAutoSettings . OFF
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи.
if ( requestedTalk ) {
NativeLib . radioTalkBegin ( audioGroup )
@ -490,7 +515,7 @@ class RadioAudioEngine {
name = " radio-capture " ; priority = Thread . MAX _PRIORITY ; start ( )
}
LogManager . addLog ( " INFO " , " debug " ,
" capture started group= $audioGroup ch= $channels vad= $vad Mode " +
" capture started group= $audioGroup ch= $channels mode= $auto Mode " +
" 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
@ -509,20 +534,21 @@ class RadioAudioEngine {
rec . release ( )
audioRecord = null
NativeLib . radioCaptureStop ( )
LogManager . addLog ( " INFO " , " debug " , " VAD /PTT capture released group=$audioGroup " )
LogManager . addLog ( " INFO " , " debug " , " Auto /PTT capture released group=$audioGroup " )
}
vadMode = false
autoMode = RadioAutoSettings . OFF
talking = false
if ( _transmittingGroup . value != null ) {
LogManager . addLog ( " DEBUG " , " radio " , " TX cleared after capture stop group= ${_transmittingGroup.value} " )
_transmittingGroup . value = null
}
_talkingFlow . value = requestedTalk
_vad ActiveFlow . value = false
_auto ActiveFlow . value = false
}
private fun releaseLocked ( ) {
mainHandler . removeCallbacks ( vadTimeout )
settingsReady = false ; ++ settingsRequest
vadWindow . setEnabled ( false , SystemClock . elapsedRealtime ( ) )
screenReceiver ?. let { ChatApplication . instance . unregisterReceiver ( it ) }
screenReceiver = null
@ -638,12 +664,13 @@ class RadioAudioEngine {
maxRmsDb = maxOf ( maxRmsDb , inputDb )
statsFrames ++
frameN ++
val vadAllowed = vadMode && screenInteractive && vadWindow . isAllowed ( SystemClock . elapsedRealtime ( ) )
val feedAllowed = active && groupId == group && captureRunning && ( ( talking && requestedTalk ) || vadAllowed )
val autoAllowed = autoMode != RadioAutoSettings . OFF && screenInteractive && vadWindow . isAllowed ( SystemClock . elapsedRealtime ( ) )
val feedAllowed = active && groupId == group && captureRunning &&
( ( talking && requestedTalk ) || autoAllowed || ( levelMonitoring && screenInteractive ) )
if ( previousFeedAllowed != feedAllowed ) {
LogManager . addLog ( " DEBUG " , " debug " , " VAD PCM gate group=$group frame= $frameN allowed= $feedAllowed " +
LogManager . addLog ( " DEBUG " , " debug " , " Auto PCM gate group=$group frame= $frameN allowed= $feedAllowed " +
" active= $active currentGroup= $groupId capture= $captureRunning manual= $requestedTalk talking= $talking " +
" nativeVad= $vad Mode screen= $screenInteractive backgroundDeadlineMs= ${vadWindow.deadlineMs} " )
" nativeMode= $auto Mode screen= $screenInteractive backgroundDeadlineMs= ${vadWindow.deadlineMs} " )
previousFeedAllowed = feedAllowed
}
if ( feedAllowed ) {
@ -653,7 +680,7 @@ class RadioAudioEngine {
maxFeedNs = maxOf ( maxFeedNs , SystemClock . elapsedRealtimeNanos ( ) - feedStartedNs )
if ( fed ) fedFrames ++ else {
failedFrames ++
if ( failedFrames == 1 ) LogManager . addLog ( " WARN " , " debug " , " VAD /PTT PCM rejected group=$group frame= $frameN " )
if ( failedFrames == 1 ) LogManager . addLog ( " WARN " , " debug " , " Auto /PTT PCM rejected group=$group frame= $frameN " )
}
if ( fed && requestedTalk && requestNs != reportedRequestNs ) {
reportedRequestNs = requestNs
@ -664,15 +691,15 @@ class RadioAudioEngine {
val transmitting = if ( captureRunning && active && groupId == group && NativeLib . radioTransmitting ( ) ) group else null
if ( _transmittingGroup . value != transmitting ) {
_transmittingGroup . value = transmitting
LogManager . addLog ( " DEBUG " , " radio " , " TX state group= $group on= ${transmitting != null} manual= $requestedTalk vad= $vad Mode " )
LogManager . addLog ( " DEBUG " , " radio " , " TX state group= $group on= ${transmitting != null} manual= $requestedTalk mode= $auto Mode " )
}
val nowMs = SystemClock . elapsedRealtime ( )
if ( nowMs - statsMs >= 1000L ) {
LogManager . addLog ( " DEBUG " , " debug " , " VAD capture summary group=$group spanMs= ${nowMs - statsMs} " +
LogManager . addLog ( " DEBUG " , " debug " , " Auto capture summary group=$group spanMs= ${nowMs - statsMs} " +
" frames= $statsFrames audioMs= ${statsFrames * FRAME_MS} fed= $fedFrames skipped= $skippedFrames rejected= $failedFrames " +
" rmsDb= $minRmsDb .. $maxRmsDb readMaxMs= ${maxReadNs / 1_000_000.0} feedMaxMs= ${maxFeedNs / 1_000_000.0} " +
" source= ${rec.audioSource} channels= $ch routed= ${rec.routedDevice?.type ?: "-"} input= $configuredInput " +
" route= ${route()} vad= $vad Mode screen= $screenInteractive manual= $requestedTalk tx= ${_talkingFlow.value} " )
" route= ${route()} mode= $auto Mode screen= $screenInteractive manual= $requestedTalk tx= ${_talkingFlow.value} " )
statsMs = nowMs
statsFrames = 0 ; fedFrames = 0 ; skippedFrames = 0 ; failedFrames = 0
minRmsDb = 0 ; maxRmsDb = - 120 ; maxReadNs = 0L ; maxFeedNs = 0L