Improve Android VAD lifecycle, audio settings and diagnostics
This commit is contained in:
1 parent
20a1211e09
commit
123b47c612
24 files changed
+490
-127
No files matched your search
@@ -3,7 +3,6 @@ package com.utun.chat
|
||||
import android.app.Application
|
||||
import android.content.res.Configuration
|
||||
import com.utun.chat.data.AppLanguage
|
||||
import android.util.Log
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.utun.chat.data.LogManager
|
||||
@@ -29,7 +28,6 @@ object AppEventHandler : ChatEventCallback {
|
||||
if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) {
|
||||
mainHandler.post { CallController.onEvent(type, data) }
|
||||
}
|
||||
Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}")
|
||||
_events.tryEmit(type to data)
|
||||
}
|
||||
|
||||
|
||||
@@ -471,6 +471,7 @@ class MainActivity : AppCompatActivity() {
|
||||
MicrophonePermission.requester = microphoneRequester
|
||||
RadioOverlayService.overlayPermissionRequester = overlayPermissionRequester
|
||||
super.onStart()
|
||||
RadioAudioEngine.instance().setForeground(true)
|
||||
if (intent.getBooleanExtra(MicrophonePermission.EXTRA_REQUEST, false)) {
|
||||
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
|
||||
requestMicrophone("PTT") {
|
||||
@@ -546,6 +547,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
RadioAudioEngine.instance().setForeground(false)
|
||||
super.onStop()
|
||||
if (MicrophonePermission.requester === microphoneRequester) MicrophonePermission.requester = null
|
||||
if (RadioOverlayService.overlayPermissionRequester === overlayPermissionRequester) RadioOverlayService.overlayPermissionRequester = null
|
||||
|
||||
@@ -23,6 +23,16 @@ import java.util.concurrent.ThreadLocalRandom
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("utun_config")
|
||||
|
||||
data class VoiceSettings(
|
||||
val preset: Int = 1,
|
||||
val compressorEnabled: Boolean = true,
|
||||
val maxGainDb: Int = 15,
|
||||
val lookbackMs: Int = 200,
|
||||
val lookaheadMs: Int = 100,
|
||||
val riseRate: Int = 6,
|
||||
val targetLevelDb: Int = -3
|
||||
)
|
||||
|
||||
class ConfigProvider(private val context: Context) {
|
||||
private val nodePrivateKey = stringPreferencesKey("node_private_key")
|
||||
private val nodePublicKey = stringPreferencesKey("node_public_key")
|
||||
@@ -44,6 +54,9 @@ class ConfigProvider(private val context: Context) {
|
||||
private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled")
|
||||
private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db")
|
||||
private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate")
|
||||
private val chatCompressorLookbackMs = intPreferencesKey("chat_compressor_lookback_ms")
|
||||
private val chatCompressorLookaheadMs = intPreferencesKey("chat_compressor_lookahead_ms")
|
||||
private val chatCompressorTargetLevelDb = intPreferencesKey("chat_compressor_target_level_db")
|
||||
private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers")
|
||||
private val chatStandbyActiveSec = intPreferencesKey("chat_standby_active_sec")
|
||||
private val chatStandbySleepSec = intPreferencesKey("chat_standby_sleep_sec")
|
||||
@@ -142,7 +155,7 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "chat.call_jitter_max_reserve_ms" -> context.dataStore.data.first()[callJitterMaxReserveMs] ?: 1000
|
||||
key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 1
|
||||
key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 15
|
||||
key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 2
|
||||
key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 6
|
||||
key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3
|
||||
key == "chat.standby_active_sec" -> context.dataStore.data.first()[chatStandbyActiveSec] ?: 2
|
||||
key == "chat.standby_sleep_sec" -> context.dataStore.data.first()[chatStandbySleepSec] ?: 60
|
||||
@@ -184,7 +197,7 @@ class ConfigProvider(private val context: Context) {
|
||||
"chat.call_jitter_max_reserve_ms" -> prefs[callJitterMaxReserveMs] = (value.toIntOrNull() ?: 1000).coerceIn(500, 2000)
|
||||
"chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 1
|
||||
"chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 15
|
||||
"chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 2
|
||||
"chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 6
|
||||
"chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3
|
||||
"chat.standby_active_sec" -> prefs[chatStandbyActiveSec] = value.toIntOrNull() ?: 2
|
||||
"chat.standby_sleep_sec" -> prefs[chatStandbySleepSec] = value.toIntOrNull() ?: 60
|
||||
@@ -204,6 +217,35 @@ class ConfigProvider(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getVoiceSettings(): VoiceSettings = runBlocking {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val defaults = VoiceSettings()
|
||||
val settings = VoiceSettings(
|
||||
preset = prefs[chatOpusCodecPreset] ?: defaults.preset,
|
||||
compressorEnabled = (prefs[chatCompressorEnabled] ?: 1) != 0,
|
||||
maxGainDb = prefs[chatCompressorMaxGainDb] ?: defaults.maxGainDb,
|
||||
lookbackMs = prefs[chatCompressorLookbackMs] ?: defaults.lookbackMs,
|
||||
lookaheadMs = prefs[chatCompressorLookaheadMs] ?: defaults.lookaheadMs,
|
||||
riseRate = prefs[chatCompressorRiseRate] ?: defaults.riseRate,
|
||||
targetLevelDb = prefs[chatCompressorTargetLevelDb] ?: defaults.targetLevelDb
|
||||
)
|
||||
LogManager.addLog("INFO", "AudioSettings", "Loaded: $settings")
|
||||
settings
|
||||
}
|
||||
|
||||
suspend fun saveVoiceSettings(settings: VoiceSettings) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[chatOpusCodecPreset] = settings.preset
|
||||
prefs[chatCompressorEnabled] = if (settings.compressorEnabled) 1 else 0
|
||||
prefs[chatCompressorMaxGainDb] = settings.maxGainDb
|
||||
prefs[chatCompressorLookbackMs] = settings.lookbackMs
|
||||
prefs[chatCompressorLookaheadMs] = settings.lookaheadMs
|
||||
prefs[chatCompressorRiseRate] = settings.riseRate
|
||||
prefs[chatCompressorTargetLevelDb] = settings.targetLevelDb
|
||||
}
|
||||
LogManager.addLog("INFO", "AudioSettings", "Saved: $settings")
|
||||
}
|
||||
|
||||
suspend fun generateKeys() {
|
||||
/* Keys are generated in C (instance_lite via ensure_keys).
|
||||
* They are saved back via NativeLib.start() → nativeGetGeneratedKeys(). */
|
||||
|
||||
@@ -16,11 +16,23 @@ object NativeLib {
|
||||
|
||||
/** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */
|
||||
fun init(dbPath: String, controlPort: Int): Boolean {
|
||||
return nativeInit(
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val settings = provider.getVoiceSettings()
|
||||
val ok = nativeInit(
|
||||
dbPath, controlPort,
|
||||
ChatApplication.instance.configProvider,
|
||||
provider,
|
||||
LogManager
|
||||
)
|
||||
if (!ok) {
|
||||
LogManager.addLog("ERROR", "AudioSettings", "Native initialization failed; voice settings not applied")
|
||||
return false
|
||||
}
|
||||
nativeVoiceSetPreset(settings.preset)
|
||||
nativeVoiceSetCompressor(settings.compressorEnabled)
|
||||
nativeVoiceSetCompressorConfig(settings.maxGainDb, settings.lookbackMs, settings.lookaheadMs,
|
||||
settings.riseRate.toFloat(), settings.targetLevelDb.toFloat())
|
||||
LogManager.addLog("INFO", "AudioSettings", "Applied at initialization: $settings")
|
||||
return true
|
||||
}
|
||||
|
||||
/** Start full uTun instance from INI config text */
|
||||
|
||||
@@ -43,7 +43,7 @@ class RadioAudioEngine {
|
||||
const val PLAY_CHANNELS = 2
|
||||
|
||||
const val RELEASE_HOLD_MS = 250L
|
||||
const val VAD_ACTIVE_MS = 15 * 60 * 1000L
|
||||
const val VAD_BACKGROUND_MS = 15 * 60 * 1000L
|
||||
|
||||
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
|
||||
@Volatile private var inst: RadioAudioEngine? = null
|
||||
@@ -76,8 +76,7 @@ class RadioAudioEngine {
|
||||
@Volatile private var talkRequestNs = 0L
|
||||
@Volatile private var generation = 0L
|
||||
private var audioGroup = 0L
|
||||
@Volatile private var vadDeadline = 0L
|
||||
private var vadConfigured = false
|
||||
private val vadWindow = RadioVadWindow(VAD_BACKGROUND_MS)
|
||||
private var screenReceiver: BroadcastReceiver? = null
|
||||
@Volatile private var screenInteractive = false
|
||||
private var configuredInput = INPUT_AUTO
|
||||
@@ -102,8 +101,8 @@ class RadioAudioEngine {
|
||||
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)")
|
||||
if (active && vadWindow.deadlineMs > 0L && !vadWindow.isAllowed(SystemClock.elapsedRealtime())) {
|
||||
LogManager.addLog("INFO", "debug", "VAD background window expired (15 minutes) group=$audioGroup")
|
||||
updateCapture()
|
||||
}
|
||||
}
|
||||
@@ -142,7 +141,7 @@ class RadioAudioEngine {
|
||||
if (!active || generation != version) return@execute
|
||||
releaseLocked()
|
||||
audioGroup = group
|
||||
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
|
||||
vadWindow.restart(SystemClock.elapsedRealtime())
|
||||
router.setAutoHeadset(true)
|
||||
if (active && generation == version) {
|
||||
try { setupAudio() } catch (e: Exception) {
|
||||
@@ -164,17 +163,29 @@ class RadioAudioEngine {
|
||||
io.execute { releaseLocked() }
|
||||
}
|
||||
|
||||
/** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */
|
||||
fun reconfigure() {
|
||||
/** Смена настроек сохраняет окно VAD; явное включение открывает новое. */
|
||||
fun reconfigure(restartVad: Boolean = false) {
|
||||
val version = generation
|
||||
io.execute {
|
||||
if (!active || generation != version || audioTrack == null) return@execute
|
||||
stopCapture()
|
||||
loadCaptureSettings()
|
||||
if (restartVad) 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 " +
|
||||
"backgroundLeftMs=${vadWindow.timeLeftMs(nowMs)}")
|
||||
if (active) updateCapture()
|
||||
}
|
||||
}
|
||||
|
||||
/* Приём создаётся независимо от микрофона (только io-поток). */
|
||||
private fun setupAudio() {
|
||||
loadCaptureSettings()
|
||||
@@ -356,12 +367,12 @@ class RadioAudioEngine {
|
||||
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")
|
||||
vadWindow.close()
|
||||
LogManager.addLog("INFO", "debug", "VAD screen off: window closed group=$audioGroup")
|
||||
}
|
||||
if (intent.action == Intent.ACTION_SCREEN_ON) {
|
||||
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
|
||||
LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)")
|
||||
vadWindow.restart(SystemClock.elapsedRealtime())
|
||||
LogManager.addLog("INFO", "debug", "VAD screen on: new background window (15 minutes) group=$audioGroup")
|
||||
}
|
||||
updateCapture()
|
||||
}
|
||||
@@ -379,14 +390,24 @@ class RadioAudioEngine {
|
||||
screenInteractive = screenOn()
|
||||
}
|
||||
|
||||
/** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */
|
||||
/** VAD без ограничения в приложении; после сворачивания — полные 15 минут при горящем экране. */
|
||||
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))
|
||||
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
|
||||
@@ -411,7 +432,7 @@ class RadioAudioEngine {
|
||||
|
||||
private fun startCapture(vad: Boolean): Boolean {
|
||||
if (!MicrophonePermission.granted()) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "Capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad")
|
||||
LogManager.addLog("WARN", "debug", "VAD/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad")
|
||||
return false
|
||||
}
|
||||
val startNs = SystemClock.elapsedRealtimeNanos()
|
||||
@@ -425,7 +446,7 @@ class RadioAudioEngine {
|
||||
}
|
||||
vadMode = NativeLib.radioVadMode()
|
||||
if (vad && !vadMode) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled")
|
||||
LogManager.addLog("ERROR", "debug", "VAD unavailable: capture cancelled group=$audioGroup")
|
||||
NativeLib.radioCaptureStop(); rec.release()
|
||||
return false
|
||||
}
|
||||
@@ -465,7 +486,7 @@ class RadioAudioEngine {
|
||||
}.apply {
|
||||
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
|
||||
}
|
||||
LogManager.addLog("INFO", "RadioAudio",
|
||||
LogManager.addLog("INFO", "debug",
|
||||
"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}")
|
||||
@@ -485,7 +506,7 @@ class RadioAudioEngine {
|
||||
rec.release()
|
||||
audioRecord = null
|
||||
NativeLib.radioCaptureStop()
|
||||
LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup")
|
||||
LogManager.addLog("INFO", "debug", "VAD/PTT capture released group=$audioGroup")
|
||||
}
|
||||
vadMode = false
|
||||
talking = false
|
||||
@@ -495,6 +516,7 @@ class RadioAudioEngine {
|
||||
|
||||
private fun releaseLocked() {
|
||||
mainHandler.removeCallbacks(vadTimeout)
|
||||
vadWindow.setEnabled(false, SystemClock.elapsedRealtime())
|
||||
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) }
|
||||
screenReceiver = null
|
||||
stopCapture()
|
||||
@@ -573,6 +595,16 @@ class RadioAudioEngine {
|
||||
val buf = ShortArray(frame)
|
||||
var frameN = 0L
|
||||
var reportedRequestNs = 0L
|
||||
var statsMs = SystemClock.elapsedRealtime()
|
||||
var statsFrames = 0
|
||||
var fedFrames = 0
|
||||
var skippedFrames = 0
|
||||
var failedFrames = 0
|
||||
var minRmsDb = 0
|
||||
var maxRmsDb = -120
|
||||
var maxReadNs = 0L
|
||||
var maxFeedNs = 0L
|
||||
var previousFeedAllowed: Boolean? = null
|
||||
while (captureRunning) {
|
||||
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
if (captureRunning) {
|
||||
@@ -580,6 +612,7 @@ class RadioAudioEngine {
|
||||
}
|
||||
break
|
||||
}
|
||||
val readStartedNs = SystemClock.elapsedRealtimeNanos()
|
||||
var read = 0
|
||||
while (read < frame) {
|
||||
if (!captureRunning) return
|
||||
@@ -592,22 +625,45 @@ class RadioAudioEngine {
|
||||
}
|
||||
read += n
|
||||
}
|
||||
/* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */
|
||||
if (frameN < 10 || frameN % 100L == 0L) {
|
||||
LogManager.addLog("DEBUG", "RadioAudio",
|
||||
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}")
|
||||
}
|
||||
maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs)
|
||||
val inputDb = rmsDb(buf)
|
||||
minRmsDb = minOf(minRmsDb, inputDb)
|
||||
maxRmsDb = maxOf(maxRmsDb, inputDb)
|
||||
statsFrames++
|
||||
frameN++
|
||||
val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline
|
||||
if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) {
|
||||
val vadAllowed = vadMode && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime())
|
||||
val feedAllowed = active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)
|
||||
if (previousFeedAllowed != feedAllowed) {
|
||||
LogManager.addLog("DEBUG", "debug", "VAD PCM gate group=$group frame=$frameN allowed=$feedAllowed " +
|
||||
"active=$active currentGroup=$groupId capture=$captureRunning manual=$requestedTalk talking=$talking " +
|
||||
"nativeVad=$vadMode screen=$screenInteractive backgroundDeadlineMs=${vadWindow.deadlineMs}")
|
||||
previousFeedAllowed = feedAllowed
|
||||
}
|
||||
if (feedAllowed) {
|
||||
val requestNs = talkRequestNs
|
||||
val feedStartedNs = SystemClock.elapsedRealtimeNanos()
|
||||
val fed = NativeLib.radioAudioFeed(group, buf)
|
||||
if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
|
||||
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 (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()
|
||||
} else skippedFrames++
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
if (nowMs - statsMs >= 1000L) {
|
||||
LogManager.addLog("DEBUG", "debug", "VAD 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=$vadMode 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
|
||||
}
|
||||
}
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done")
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
/** Окно VAD расходуется только в фоне; все изменения выполняются в radio-audio-io. */
|
||||
class RadioVadWindow(private val durationMs: Long) {
|
||||
@Volatile var deadlineMs = 0L
|
||||
private set
|
||||
var foreground = false
|
||||
private set
|
||||
private var enabled = false
|
||||
private var remainingMs = durationMs
|
||||
|
||||
fun setForeground(value: Boolean, nowMs: Long) {
|
||||
if (foreground == value) return
|
||||
if (value) remainingMs = durationMs
|
||||
foreground = value
|
||||
updateDeadline(nowMs)
|
||||
}
|
||||
|
||||
fun setEnabled(value: Boolean, nowMs: Long) {
|
||||
if (enabled == value) return
|
||||
enabled = value
|
||||
if (value) restart(nowMs) else close()
|
||||
}
|
||||
|
||||
fun restart(nowMs: Long) {
|
||||
remainingMs = durationMs
|
||||
updateDeadline(nowMs)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
remainingMs = 0L
|
||||
deadlineMs = 0L
|
||||
}
|
||||
|
||||
fun isAllowed(nowMs: Long): Boolean = nowMs < deadlineMs
|
||||
|
||||
fun timeLeftMs(nowMs: Long): Long =
|
||||
if (deadlineMs == Long.MAX_VALUE) remainingMs else (deadlineMs - nowMs).coerceAtLeast(0L)
|
||||
|
||||
private fun updateDeadline(nowMs: Long) {
|
||||
deadlineMs = when {
|
||||
!enabled -> 0L
|
||||
foreground -> Long.MAX_VALUE
|
||||
else -> nowMs + remainingMs
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -1,6 +1,7 @@
|
||||
package com.utun.chat.ui.screens
|
||||
|
||||
import com.utun.chat.R
|
||||
import com.utun.chat.BuildConfig
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -28,6 +29,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.DmConversation
|
||||
import com.utun.chat.data.NotificationSound
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -53,7 +55,10 @@ fun ChannelListScreen(
|
||||
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
|
||||
var deleteConfirmChannel by remember { mutableStateOf<Channel?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) { onRefresh() }
|
||||
LaunchedEffect(Unit) {
|
||||
LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}")
|
||||
onRefresh()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -72,8 +77,16 @@ fun ChannelListScreen(
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
||||
Icon(Icons.Default.Add, stringResource(R.string.new_channel))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "v${BuildConfig.VERSION_NAME}",
|
||||
modifier = Modifier.padding(end = 16.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
FloatingActionButton(onClick = { showCreateDialog = true }) {
|
||||
Icon(Icons.Default.Add, stringResource(R.string.new_channel))
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
|
||||
@@ -260,7 +260,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
val scope = rememberCoroutineScope()
|
||||
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
|
||||
val vadEnabled by provider.radioVadEnabled.collectAsState(initial = provider.getInt("radio.vad_enabled") != 0)
|
||||
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
|
||||
val volumePttEnabled by provider.radioVolumePttEnabled.collectAsState(initial = provider.getRadioVolumePtt())
|
||||
val overlayShown by RadioOverlayService.isShown.collectAsState()
|
||||
val overlayStatus by RadioOverlayService.status.collectAsState()
|
||||
@@ -305,9 +305,9 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
|
||||
volumePttEnabled, volumeKeys = true) {
|
||||
viewModel.setRadioVolumePtt(!volumePttEnabled)
|
||||
}
|
||||
RadioControlButton(null, stringResource(if (vadEnabled) R.string.disable_vad else R.string.enable_vad),
|
||||
vadEnabled, text = stringResource(R.string.vad_button)) {
|
||||
viewModel.setRadioVad(!vadEnabled)
|
||||
RadioControlButton(null, stringResource(if (vadActive) R.string.disable_vad else R.string.enable_vad),
|
||||
vadActive, text = stringResource(R.string.vad_button)) {
|
||||
viewModel.setRadioVad(!vadActive)
|
||||
}
|
||||
RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) {
|
||||
scope.launch {
|
||||
|
||||
+24
-18
@@ -32,6 +32,7 @@ import com.utun.chat.data.RadioAudioEngine
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.ServerEntry
|
||||
import com.utun.chat.data.LocalSocketState
|
||||
import com.utun.chat.data.VoiceSettings
|
||||
import com.utun.chat.headless.HeadlessService
|
||||
import com.utun.chat.ui.components.BatteryOptimizationSetting
|
||||
import com.utun.chat.ui.components.LanguageSetting
|
||||
@@ -42,6 +43,7 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -49,6 +51,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
import java.io.IOException
|
||||
|
||||
private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE")
|
||||
private val CATEGORIES = listOf(
|
||||
@@ -773,28 +776,31 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
|
||||
/* Codec preset */
|
||||
val presets = listOf(stringResource(R.string.codec_low), stringResource(R.string.codec_standard), stringResource(R.string.codec_high))
|
||||
var preset by remember { mutableStateOf(vm.getVoicePreset()) }
|
||||
val savedVoiceSettings = remember { provider.getVoiceSettings() }
|
||||
var preset by remember { mutableStateOf(savedVoiceSettings.preset) }
|
||||
var presetExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
var jitterReserveMs by remember { mutableStateOf(provider.getInt("chat.call_jitter_max_reserve_ms")) }
|
||||
|
||||
/* Compressor params with defaults from desktop */
|
||||
var compressorOn by remember { mutableStateOf(vm.getVoiceCompressor()) }
|
||||
var maxGainDb by remember { mutableStateOf(15) }
|
||||
var lookbackMs by remember { mutableStateOf(200) }
|
||||
var lookaheadMs by remember { mutableStateOf(100) }
|
||||
var riseRate by remember { mutableStateOf(2) }
|
||||
var targetLevelDb by remember { mutableStateOf(-12) }
|
||||
/* Все параметры восстанавливаются из одного снимка DataStore. */
|
||||
var compressorOn by remember { mutableStateOf(savedVoiceSettings.compressorEnabled) }
|
||||
var maxGainDb by remember { mutableStateOf(savedVoiceSettings.maxGainDb) }
|
||||
var lookbackMs by remember { mutableStateOf(savedVoiceSettings.lookbackMs) }
|
||||
var lookaheadMs by remember { mutableStateOf(savedVoiceSettings.lookaheadMs) }
|
||||
var riseRate by remember { mutableStateOf(savedVoiceSettings.riseRate) }
|
||||
var targetLevelDb by remember { mutableStateOf(savedVoiceSettings.targetLevelDb) }
|
||||
|
||||
val apply = {
|
||||
vm.setVoicePreset(preset)
|
||||
vm.setVoiceCompressor(compressorOn)
|
||||
vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate.toFloat(), targetLevelDb.toFloat())
|
||||
scope.launch {
|
||||
provider.setValue("chat.opus_codec_preset", preset.toString())
|
||||
provider.setValue("chat.compressor_enabled", if (compressorOn) "1" else "0")
|
||||
provider.setValue("chat.compressor_max_gain_db", maxGainDb.toString())
|
||||
provider.setValue("chat.compressor_rise_rate", riseRate.toString())
|
||||
val settings = VoiceSettings(preset, compressorOn, maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb)
|
||||
vm.viewModelScope.launch {
|
||||
try {
|
||||
provider.saveVoiceSettings(settings)
|
||||
} catch (e: IOException) {
|
||||
LogManager.addLog("ERROR", "AudioSettings", "Save failed: $settings: $e")
|
||||
}
|
||||
}
|
||||
NativeLib.setChatSetting("opus_codec_preset", preset.toString())
|
||||
NativeLib.setChatSetting("compressor_enabled", if (compressorOn) "1" else "0")
|
||||
@@ -915,15 +921,15 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
}
|
||||
|
||||
LabeledSlider(stringResource(R.string.max_gain), maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB",
|
||||
{ maxGainDb = it.toInt(); apply() })
|
||||
{ maxGainDb = it.toInt() }, { apply() })
|
||||
LabeledSlider(stringResource(R.string.lookback_time), lookbackMs.toFloat(), 50f..500f, 45, stringResource(R.string.milliseconds, lookbackMs),
|
||||
{ lookbackMs = it.toInt(); apply() })
|
||||
{ lookbackMs = it.toInt() }, { apply() })
|
||||
LabeledSlider(stringResource(R.string.lookahead_time), lookaheadMs.toFloat(), 20f..500f, 48, stringResource(R.string.milliseconds, lookaheadMs),
|
||||
{ lookaheadMs = it.toInt(); apply() })
|
||||
{ lookaheadMs = it.toInt() }, { apply() })
|
||||
LabeledSlider(stringResource(R.string.rise_rate), riseRate.toFloat(), 2f..10f, 8, "${riseRate}x",
|
||||
{ riseRate = it.toInt(); apply() })
|
||||
{ riseRate = it.toInt() }, { apply() })
|
||||
LabeledSlider(stringResource(R.string.target_level), targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS",
|
||||
{ targetLevelDb = it.toInt(); apply() })
|
||||
{ targetLevelDb = it.toInt() }, { apply() })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -677,7 +677,7 @@ class ChatViewModel : ViewModel() {
|
||||
NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0")
|
||||
LogManager.addLog("INFO", "RadioAudio", "Panel VAD enabled=$enabled channel=$radioChannelId")
|
||||
if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx)
|
||||
radioAudio.reconfigure()
|
||||
radioAudio.reconfigure(restartVad = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user