Add independent radio compressor and Android audio controls
This commit is contained in:
1 parent
ce1f23fe46
commit
fdec5b65c5
9 files changed
+196
-27
No files matched your search
@@ -42,6 +42,14 @@ class CallAudioRouter {
|
||||
@Volatile var hasEarpiece = true
|
||||
private set
|
||||
|
||||
/** Куда откатываться при потере гарнитуры (звонок — EARPIECE, рация — SPEAKER). */
|
||||
@Volatile var fallbackRoute = AudioRoute.EARPIECE
|
||||
|
||||
/** Автоматически следовать за гарнитурой: появилась → HEADSET, исчезла → fallbackRoute.
|
||||
* Ручной setRoute() сбрасывает в false. */
|
||||
@Volatile var autoHeadset = false
|
||||
private set
|
||||
|
||||
/** Уведомление об изменении маршрута или доступности гарнитуры. */
|
||||
var onChanged: ((AudioRoute, Boolean) -> Unit)? = null
|
||||
|
||||
@@ -73,10 +81,10 @@ class CallAudioRouter {
|
||||
bindBtHeadsetProfile()
|
||||
|
||||
hasEarpiece = findBuiltinEarpiece() != null
|
||||
route = preferredRoute
|
||||
route = if (autoHeadset) (if (isHeadsetConnected()) AudioRoute.HEADSET else fallbackRoute) else preferredRoute
|
||||
applyRoute(route)
|
||||
refresh()
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece)")
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "started (hasEarpiece=$hasEarpiece autoHeadset=$autoHeadset fallback=$fallbackRoute)")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@@ -102,6 +110,7 @@ class CallAudioRouter {
|
||||
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
|
||||
return
|
||||
}
|
||||
autoHeadset = false
|
||||
if (route == r) return
|
||||
route = r
|
||||
preferredRoute = r
|
||||
@@ -116,6 +125,12 @@ class CallAudioRouter {
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "preferredRoute -> $r")
|
||||
}
|
||||
|
||||
/** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */
|
||||
fun setAutoHeadset(enabled: Boolean) {
|
||||
autoHeadset = enabled
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled")
|
||||
}
|
||||
|
||||
/* ── применение маршрута ── */
|
||||
private fun applyRoute(r: AudioRoute) {
|
||||
try {
|
||||
@@ -203,17 +218,27 @@ class CallAudioRouter {
|
||||
if (now != prev) {
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "headsetAvailable -> $now")
|
||||
}
|
||||
/* гарнитуру отключили — откатываемся на динамик */
|
||||
if (!now && route == AudioRoute.HEADSET) {
|
||||
route = AudioRoute.EARPIECE
|
||||
applyRoute(route)
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> EARPIECE")
|
||||
}
|
||||
/* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */
|
||||
if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) {
|
||||
route = AudioRoute.HEADSET
|
||||
applyRoute(route)
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET")
|
||||
/* авторежим: маршрут следует за гарнитурой */
|
||||
if (autoHeadset) {
|
||||
val want = if (now) AudioRoute.HEADSET else fallbackRoute
|
||||
if (route != want) {
|
||||
route = want
|
||||
applyRoute(route)
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "auto route -> $want")
|
||||
}
|
||||
} else {
|
||||
/* гарнитуру отключили — откатываемся на fallback-маршрут */
|
||||
if (!now && route == AudioRoute.HEADSET) {
|
||||
route = fallbackRoute
|
||||
applyRoute(route)
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "headset lost, fallback route -> $fallbackRoute")
|
||||
}
|
||||
/* гарнитура появилась, а пользователь ранее выбрал её — восстанавливаем */
|
||||
if (now && !prev && preferredRoute == AudioRoute.HEADSET && route != AudioRoute.HEADSET) {
|
||||
route = AudioRoute.HEADSET
|
||||
applyRoute(route)
|
||||
LogManager.addLog("INFO", "CallAudioRouter", "headset available, restore preferred route -> HEADSET")
|
||||
}
|
||||
}
|
||||
if (now != prev || route == AudioRoute.HEADSET) {
|
||||
onChanged?.invoke(route, now)
|
||||
|
||||
@@ -60,6 +60,8 @@ class ConfigProvider(private val context: Context) {
|
||||
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
|
||||
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
|
||||
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
|
||||
private val radioCompressorEnabledKey = intPreferencesKey("radio_compressor_enabled")
|
||||
private val radioCompressorMaxGainDbKey = intPreferencesKey("radio_compressor_max_gain_db")
|
||||
|
||||
private fun keysFilePath(): String {
|
||||
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
|
||||
@@ -141,6 +143,8 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
|
||||
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
|
||||
key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0
|
||||
key == "radio.compressor_enabled" -> context.dataStore.data.first()[radioCompressorEnabledKey] ?: 1
|
||||
key == "radio.compressor_max_gain_db" -> context.dataStore.data.first()[radioCompressorMaxGainDbKey] ?: 15
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -176,6 +180,8 @@ class ConfigProvider(private val context: Context) {
|
||||
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
|
||||
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
|
||||
"radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0
|
||||
"radio.compressor_enabled" -> prefs[radioCompressorEnabledKey] = value.toIntOrNull() ?: 1
|
||||
"radio.compressor_max_gain_db" -> prefs[radioCompressorMaxGainDbKey] = value.toIntOrNull() ?: 15
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -477,6 +483,8 @@ class ConfigProvider(private val context: Context) {
|
||||
val radioOpusMode = getInt("radio.opus_mode")
|
||||
val radioOpusBitrate = getInt("radio.opus_bitrate")
|
||||
val radioCaptureStereo = getInt("radio.capture_stereo")
|
||||
val radioCompEnabled = getInt("radio.compressor_enabled")
|
||||
val radioCompMaxGain = getInt("radio.compressor_max_gain_db")
|
||||
val chatCompEnabled = getInt("chat.compressor_enabled")
|
||||
val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
|
||||
val chatCompRiseRate = getInt("chat.compressor_rise_rate")
|
||||
@@ -488,6 +496,8 @@ class ConfigProvider(private val context: Context) {
|
||||
appendLine("radio_opus_mode=$radioOpusMode")
|
||||
appendLine("radio_opus_bitrate=$radioOpusBitrate")
|
||||
appendLine("radio_capture_stereo=$radioCaptureStereo")
|
||||
appendLine("radio_compressor_enabled=$radioCompEnabled")
|
||||
appendLine("radio_compressor_max_gain_db=$radioCompMaxGain")
|
||||
appendLine("compressor_enabled=$chatCompEnabled")
|
||||
appendLine("compressor_max_gain_db=$chatCompMaxGain")
|
||||
appendLine("compressor_rise_rate=$chatCompRiseRate")
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.utun.chat.data
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
@@ -58,6 +59,9 @@ class RadioAudioEngine {
|
||||
@Volatile private var micDead = false
|
||||
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */
|
||||
|
||||
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
|
||||
val router = CallAudioRouter()
|
||||
|
||||
private val _talkingFlow = MutableStateFlow(false)
|
||||
val talkingFlow: StateFlow<Boolean> = _talkingFlow
|
||||
|
||||
@@ -74,6 +78,15 @@ class RadioAudioEngine {
|
||||
|
||||
fun isTalking(): Boolean = talking
|
||||
|
||||
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
|
||||
fun route(): AudioRoute = router.route
|
||||
|
||||
/** Доступна ли гарнитура (проводная/USB/BT). */
|
||||
fun headsetAvailable(): Boolean = router.headsetAvailable
|
||||
|
||||
/** Ручной выбор выхода (отключает автоследование за гарнитурой). */
|
||||
fun setRoute(r: AudioRoute) = router.setRoute(r)
|
||||
|
||||
/** Начать прослушивание канала (переключатель рации вкл).
|
||||
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
|
||||
fun start(group: Long) {
|
||||
@@ -128,21 +141,25 @@ class RadioAudioEngine {
|
||||
prevSpeaker = am.isSpeakerphoneOn
|
||||
try {
|
||||
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
am.isSpeakerphoneOn = true
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}")
|
||||
}
|
||||
|
||||
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, вынули → SPEAKER). */
|
||||
router.fallbackRoute = AudioRoute.SPEAKER
|
||||
router.setAutoHeadset(true)
|
||||
router.start()
|
||||
|
||||
// ── AudioRecord (захват) с откатом стерео→моно ──
|
||||
val (rec, capCh) = createAudioRecord()
|
||||
if (rec == null) {
|
||||
restoreAudioMode(); active = false; return
|
||||
router.stop(); restoreAudioMode(); active = false; return
|
||||
}
|
||||
captureChannels = capCh
|
||||
|
||||
if (!NativeLib.radioAudioStart(group, capCh)) {
|
||||
rec.release()
|
||||
restoreAudioMode(); active = false
|
||||
router.stop(); restoreAudioMode(); active = false
|
||||
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh")
|
||||
return
|
||||
}
|
||||
@@ -163,12 +180,18 @@ class RadioAudioEngine {
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
|
||||
rec.release()
|
||||
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
|
||||
NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return
|
||||
}
|
||||
|
||||
audioRecord = rec
|
||||
audioTrack = track
|
||||
micDead = false
|
||||
|
||||
/* Прямое роутирование трека на выбранное устройство (обход глючной глобальной
|
||||
* маршрутизации), как в CallAudioEngine. */
|
||||
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) }
|
||||
applyTrackOutputDevice(router.currentOutputDevice)
|
||||
|
||||
rec.startRecording()
|
||||
track.play()
|
||||
|
||||
@@ -256,6 +279,8 @@ class RadioAudioEngine {
|
||||
audioTrack = null
|
||||
|
||||
NativeLib.radioAudioStop()
|
||||
router.onOutputDevice = null
|
||||
router.stop()
|
||||
restoreAudioMode()
|
||||
|
||||
if (resetGroup) {
|
||||
@@ -367,4 +392,15 @@ class RadioAudioEngine {
|
||||
LogManager.addLog("WARN", "RadioAudio", "restore audio mode failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyTrackOutputDevice(dev: AudioDeviceInfo?) {
|
||||
val track = audioTrack ?: return
|
||||
if (dev == null) return
|
||||
try {
|
||||
track.setPreferredDevice(dev)
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "track preferred device -> type=${dev.type}")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "setPreferredDevice failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Headset
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.People
|
||||
import androidx.compose.material3.*
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.AudioRoute
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.Message
|
||||
import com.utun.chat.data.NativeLib
|
||||
@@ -69,6 +72,8 @@ fun ChatScreen(
|
||||
val radioTalking by viewModel.radioTalking.collectAsState()
|
||||
val overlayShown by RadioOverlayService.isShown.collectAsState()
|
||||
val localTalking by RadioAudioEngine.instance().talkingFlow.collectAsState()
|
||||
val radioRoute by viewModel.radioRoute.collectAsState()
|
||||
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
|
||||
|
||||
/* звук сообщений канала */
|
||||
val soundOn by viewModel.soundOn.collectAsState()
|
||||
@@ -185,12 +190,31 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
if (radioActive && !overlayShown) {
|
||||
RadioPttButton(
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
isTalking = localTalking,
|
||||
onTalkBegin = { viewModel.radioTalkBegin() },
|
||||
onTalkEnd = { viewModel.radioTalkEnd() }
|
||||
)
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
RadioRouteButton(
|
||||
active = radioRoute == AudioRoute.SPEAKER,
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.VolumeUp, "Динамик", tint = Color.White, modifier = Modifier.size(22.dp)) },
|
||||
onClick = { viewModel.setRadioRoute(AudioRoute.SPEAKER) }
|
||||
)
|
||||
if (radioHeadsetAvailable) {
|
||||
RadioRouteButton(
|
||||
active = radioRoute == AudioRoute.HEADSET,
|
||||
icon = { Icon(Icons.Filled.Headset, "Гарнитура", tint = Color.White, modifier = Modifier.size(22.dp)) },
|
||||
onClick = { viewModel.setRadioRoute(AudioRoute.HEADSET) }
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
RadioPttButton(
|
||||
isTalking = localTalking,
|
||||
onTalkBegin = { viewModel.radioTalkBegin() },
|
||||
onTalkEnd = { viewModel.radioTalkEnd() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
InputBar(
|
||||
@@ -297,3 +321,24 @@ private fun RadioPttButton(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Круглая кнопка выбора выхода рации (динамик/гарнитура). */
|
||||
@Composable
|
||||
private fun RadioRouteButton(
|
||||
active: Boolean,
|
||||
icon: @Composable () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = CircleShape,
|
||||
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D),
|
||||
shadowElevation = 3.dp
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
icon()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,6 +575,31 @@ private fun RadioSettingsTab() {
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Компрессор (AGC) рации ── */
|
||||
var radioCompOn by remember { mutableStateOf(provider.getInt("radio.compressor_enabled") != 0) }
|
||||
var radioCompMaxGain by remember { mutableStateOf(provider.getInt("radio.compressor_max_gain_db")) }
|
||||
Text("Компрессор (AGC) рации", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Enable compressor", modifier = Modifier.weight(1f))
|
||||
Switch(checked = radioCompOn, onCheckedChange = {
|
||||
radioCompOn = it
|
||||
scope.launch { provider.setValue("radio.compressor_enabled", if (it) "1" else "0") }
|
||||
NativeLib.setChatSetting("radio_compressor_enabled", if (it) "1" else "0")
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
})
|
||||
}
|
||||
LabeledSlider("Max gain:", radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB",
|
||||
{ v ->
|
||||
radioCompMaxGain = v.toInt()
|
||||
NativeLib.setChatSetting("radio_compressor_max_gain_db", radioCompMaxGain.toString())
|
||||
},
|
||||
onValueChangeFinished = {
|
||||
scope.launch { provider.setValue("radio.compressor_max_gain_db", radioCompMaxGain.toString()) }
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
})
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Opus для рации: режим + битрейт ── */
|
||||
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
|
||||
val opusBitrates = listOf(16, 32, 64, 96, 128)
|
||||
@@ -833,7 +858,8 @@ private fun StorageSettingsTab() {
|
||||
@Composable
|
||||
private fun LabeledSlider(
|
||||
label: String, value: Float, range: ClosedFloatingPointRange<Float>,
|
||||
steps: Int, displayValue: String, onValueChange: (Float) -> Unit
|
||||
steps: Int, displayValue: String, onValueChange: (Float) -> Unit,
|
||||
onValueChangeFinished: (() -> Unit)? = null
|
||||
) {
|
||||
Column {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
@@ -842,6 +868,7 @@ private fun LabeledSlider(
|
||||
}
|
||||
Slider(
|
||||
value = value, onValueChange = onValueChange,
|
||||
onValueChangeFinished = onValueChangeFinished,
|
||||
valueRange = range, steps = steps,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
@@ -65,6 +65,12 @@ class ChatViewModel : ViewModel() {
|
||||
private val _radioTalking = MutableStateFlow<Long?>(null)
|
||||
val radioTalking: StateFlow<Long?> = _radioTalking
|
||||
|
||||
private val _radioRoute = MutableStateFlow(AudioRoute.SPEAKER)
|
||||
val radioRoute: StateFlow<AudioRoute> = _radioRoute
|
||||
|
||||
private val _radioHeadsetAvailable = MutableStateFlow(false)
|
||||
val radioHeadsetAvailable: StateFlow<Boolean> = _radioHeadsetAvailable
|
||||
|
||||
private var radioToggleCount = 0
|
||||
|
||||
/* рация: активный канал (null = выключена). Одна рация — один канал. */
|
||||
@@ -764,11 +770,19 @@ class ChatViewModel : ViewModel() {
|
||||
radioChannelId = ch.id
|
||||
NativeLib.radioSetActive(groupId, true)
|
||||
radioAudio.start(groupId)
|
||||
radioAudio.router.onChanged = { r, headset ->
|
||||
_radioRoute.value = r
|
||||
_radioHeadsetAvailable.value = headset
|
||||
}
|
||||
_radioRoute.value = radioAudio.route()
|
||||
_radioHeadsetAvailable.value = radioAudio.headsetAvailable()
|
||||
} else {
|
||||
radioChannelId = null
|
||||
NativeLib.radioSetActive(groupId, false)
|
||||
radioAudio.stop()
|
||||
_radioTalking.value = null
|
||||
_radioRoute.value = AudioRoute.SPEAKER
|
||||
_radioHeadsetAvailable.value = false
|
||||
}
|
||||
_radioActive.value = next
|
||||
syncRadioOverlay()
|
||||
@@ -785,6 +799,14 @@ class ChatViewModel : ViewModel() {
|
||||
radioAudio.talkEnd()
|
||||
}
|
||||
|
||||
/** Ручной выбор выхода рации (динамик/гарнитура). */
|
||||
fun setRadioRoute(r: AudioRoute) {
|
||||
if (!_radioActive.value) return
|
||||
radioAudio.setRoute(r)
|
||||
_radioRoute.value = radioAudio.route()
|
||||
LogManager.addLog("INFO", "VM", "setRadioRoute -> ${radioAudio.route()}")
|
||||
}
|
||||
|
||||
/* Полностью выключить рацию (используется при удалении активного канала). */
|
||||
private fun turnRadioOff() {
|
||||
val chId = radioChannelId ?: return
|
||||
@@ -792,6 +814,8 @@ class ChatViewModel : ViewModel() {
|
||||
radioChannelId = null
|
||||
_radioActive.value = false
|
||||
_radioTalking.value = null
|
||||
_radioRoute.value = AudioRoute.SPEAKER
|
||||
_radioHeadsetAvailable.value = false
|
||||
radioAudio.stop()
|
||||
syncRadioOverlay()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user