Radio: Opus mode/bitrate + mic source + PTT release tail; drop online status from member JSON
This commit is contained in:
1 parent
e3caf15f32
commit
331970a121
10 files changed
+230
-40
No files matched your search
@@ -56,6 +56,9 @@ class ConfigProvider(private val context: Context) {
|
||||
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
|
||||
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
|
||||
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
|
||||
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
|
||||
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
|
||||
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
|
||||
|
||||
private fun keysFilePath(): String {
|
||||
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
|
||||
@@ -134,6 +137,8 @@ class ConfigProvider(private val context: Context) {
|
||||
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
|
||||
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
|
||||
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
|
||||
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
|
||||
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -166,6 +171,8 @@ class ConfigProvider(private val context: Context) {
|
||||
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
|
||||
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
|
||||
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
|
||||
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
|
||||
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,6 +309,23 @@ class ConfigProvider(private val context: Context) {
|
||||
context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled }
|
||||
}
|
||||
|
||||
@Volatile private var radioMicSourceCached = 0
|
||||
@Volatile private var radioMicSourceLoaded = false
|
||||
|
||||
fun getRadioMicSource(): Int {
|
||||
if (!radioMicSourceLoaded) {
|
||||
radioMicSourceCached = runBlocking { context.dataStore.data.first()[radioMicSourceKey] ?: 0 }
|
||||
radioMicSourceLoaded = true
|
||||
}
|
||||
return radioMicSourceCached
|
||||
}
|
||||
|
||||
suspend fun setRadioMicSource(source: Int) {
|
||||
radioMicSourceCached = source
|
||||
radioMicSourceLoaded = true
|
||||
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
|
||||
}
|
||||
|
||||
/* ── Server entries ── */
|
||||
|
||||
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
|
||||
@@ -430,6 +454,8 @@ class ConfigProvider(private val context: Context) {
|
||||
val chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb")
|
||||
val chatMaxGb = getInt("chat.storage_maxsize_gb")
|
||||
val chatOpusPreset = getInt("chat.opus_codec_preset")
|
||||
val radioOpusMode = getInt("radio.opus_mode")
|
||||
val radioOpusBitrate = getInt("radio.opus_bitrate")
|
||||
val chatCompEnabled = getInt("chat.compressor_enabled")
|
||||
val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
|
||||
val chatCompRiseRate = getInt("chat.compressor_rise_rate")
|
||||
@@ -438,6 +464,8 @@ class ConfigProvider(private val context: Context) {
|
||||
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
|
||||
appendLine("storage_maxsize_gb=$chatMaxGb")
|
||||
appendLine("opus_codec_preset=$chatOpusPreset")
|
||||
appendLine("radio_opus_mode=$radioOpusMode")
|
||||
appendLine("radio_opus_bitrate=$radioOpusBitrate")
|
||||
appendLine("compressor_enabled=$chatCompEnabled")
|
||||
appendLine("compressor_max_gain_db=$chatCompMaxGain")
|
||||
appendLine("compressor_rise_rate=$chatCompRiseRate")
|
||||
|
||||
@@ -7,6 +7,9 @@ import android.media.AudioManager
|
||||
import android.media.AudioRecord
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaRecorder
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -29,6 +32,9 @@ class RadioAudioEngine {
|
||||
const val FRAME_MS = 20
|
||||
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
|
||||
|
||||
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */
|
||||
const val RELEASE_HOLD_MS = 200L
|
||||
|
||||
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
|
||||
@Volatile private var inst: RadioAudioEngine? = null
|
||||
fun instance(): RadioAudioEngine = inst ?: synchronized(this) {
|
||||
@@ -56,6 +62,10 @@ class RadioAudioEngine {
|
||||
Thread(r, "radio-audio-io").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
/* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val releaseRunnable = Runnable { finishTalk() }
|
||||
|
||||
fun isActive(): Boolean = active
|
||||
|
||||
fun isTalking(): Boolean = talking
|
||||
@@ -75,6 +85,7 @@ class RadioAudioEngine {
|
||||
active = false
|
||||
talking = false
|
||||
_talkingFlow.value = false
|
||||
mainHandler.removeCallbacks(releaseRunnable)
|
||||
io.execute { stopBlocking() }
|
||||
}
|
||||
|
||||
@@ -88,7 +99,25 @@ class RadioAudioEngine {
|
||||
active = false
|
||||
releaseLocked(false)
|
||||
active = true
|
||||
setupAudio()
|
||||
}
|
||||
|
||||
/* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */
|
||||
fun reconfigure() {
|
||||
val g = groupId
|
||||
if (g == 0L) return
|
||||
io.execute {
|
||||
if (groupId != g) return@execute
|
||||
if (talking) finishTalk()
|
||||
if (active) { active = false; releaseLocked(false) }
|
||||
active = true
|
||||
setupAudio()
|
||||
}
|
||||
}
|
||||
|
||||
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */
|
||||
private fun setupAudio() {
|
||||
val group = groupId
|
||||
if (!NativeLib.radioAudioStart(group)) {
|
||||
active = false
|
||||
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
|
||||
@@ -109,18 +138,8 @@ class RadioAudioEngine {
|
||||
// ── AudioRecord (захват) ──
|
||||
val recMin = AudioRecord.getMinBufferSize(
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
|
||||
val rec = try {
|
||||
AudioRecord(
|
||||
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
|
||||
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
|
||||
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}")
|
||||
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
|
||||
}
|
||||
if (rec.state != AudioRecord.STATE_INITIALIZED) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized")
|
||||
rec.release()
|
||||
val rec = createAudioRecord((recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
|
||||
if (rec == null) {
|
||||
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
|
||||
}
|
||||
|
||||
@@ -159,6 +178,40 @@ class RadioAudioEngine {
|
||||
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf")
|
||||
}
|
||||
|
||||
/* Создание AudioRecord по выбранному источнику; UNPROCESSED с fallback на MIC. */
|
||||
private fun createAudioRecord(bufferSize: Int): AudioRecord? {
|
||||
val pref = ChatApplication.instance.configProvider.getRadioMicSource()
|
||||
val primary = when (pref) {
|
||||
1 -> MediaRecorder.AudioSource.MIC
|
||||
2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
MediaRecorder.AudioSource.UNPROCESSED else MediaRecorder.AudioSource.MIC
|
||||
3 -> MediaRecorder.AudioSource.VOICE_RECOGNITION
|
||||
else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION
|
||||
}
|
||||
val rec = tryCreateRecord(primary, bufferSize)
|
||||
if (rec != null) { LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$primary"); return rec }
|
||||
if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "UNPROCESSED unavailable — fallback to MIC")
|
||||
return tryCreateRecord(MediaRecorder.AudioSource.MIC, bufferSize)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun tryCreateRecord(source: Int, bufferSize: Int): AudioRecord? {
|
||||
val rec = try {
|
||||
AudioRecord(source, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize)
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source): ${e.message}")
|
||||
return null
|
||||
}
|
||||
if (rec.state != AudioRecord.STATE_INITIALIZED) {
|
||||
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source)")
|
||||
rec.release()
|
||||
return null
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
private fun stopBlocking() {
|
||||
releaseLocked(true)
|
||||
}
|
||||
@@ -184,7 +237,9 @@ class RadioAudioEngine {
|
||||
|
||||
/** PTT зажата — начинаем передачу. */
|
||||
fun talkBegin() {
|
||||
if (!active || talking) return
|
||||
if (!active) return
|
||||
mainHandler.removeCallbacks(releaseRunnable)
|
||||
if (talking) return
|
||||
if (micDead) {
|
||||
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)")
|
||||
}
|
||||
@@ -193,12 +248,27 @@ class RadioAudioEngine {
|
||||
NativeLib.radioTalkBegin(groupId)
|
||||
}
|
||||
|
||||
/** PTT отпущена — завершаем передачу (FIN). */
|
||||
/** 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()
|
||||
}
|
||||
|
||||
private fun finishTalk() {
|
||||
if (!talking) return
|
||||
talking = false
|
||||
_talkingFlow.value = false
|
||||
NativeLib.radioTalkEnd(groupId)
|
||||
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)")
|
||||
}
|
||||
|
||||
private fun captureLoop() {
|
||||
|
||||
@@ -185,7 +185,7 @@ class RadioOverlayService : Service() {
|
||||
dragAllowed = false
|
||||
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
|
||||
dragging = true
|
||||
endTalk()
|
||||
endTalkImmediate()
|
||||
lp.x = startX + (event.rawX - touchX).toInt()
|
||||
lp.y = startY + (event.rawY - touchY).toInt()
|
||||
wm.updateViewLayout(v, lp)
|
||||
@@ -216,10 +216,19 @@ class RadioOverlayService : Service() {
|
||||
}
|
||||
|
||||
private fun endTalk() {
|
||||
endTalkInternal(immediate = false)
|
||||
}
|
||||
|
||||
private fun endTalkImmediate() {
|
||||
endTalkInternal(immediate = true)
|
||||
}
|
||||
|
||||
private fun endTalkInternal(immediate: Boolean) {
|
||||
if (!talking) return
|
||||
talking = false
|
||||
updateButtonColor()
|
||||
RadioAudioEngine.instance().talkEnd()
|
||||
if (immediate) RadioAudioEngine.instance().talkEndImmediate()
|
||||
else RadioAudioEngine.instance().talkEnd()
|
||||
}
|
||||
|
||||
private fun updateButtonColor() {
|
||||
|
||||
@@ -471,6 +471,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
|
||||
|
||||
/* ── Radio Settings Tab ── */
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RadioSettingsTab() {
|
||||
val provider = ChatApplication.instance.configProvider
|
||||
@@ -528,6 +529,74 @@ private fun RadioSettingsTab() {
|
||||
RadioOverlayService.stop(ChatApplication.instance)
|
||||
})
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Источник микрофона ── */
|
||||
val micSources = listOf(
|
||||
"Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)",
|
||||
"Без обработки (UNPROCESSED)", "Распознавание (VOICE_RECOGNITION)")
|
||||
var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) }
|
||||
var micExpanded by remember { mutableStateOf(false) }
|
||||
Text("Источник микрофона", style = MaterialTheme.typography.titleMedium)
|
||||
ExposedDropdownMenuBox(expanded = micExpanded, onExpandedChange = { micExpanded = it }) {
|
||||
OutlinedTextField(micSources[micSource], {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(micExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
|
||||
ExposedDropdownMenu(micExpanded, onDismissRequest = { micExpanded = false }) {
|
||||
micSources.forEachIndexed { i, n ->
|
||||
DropdownMenuItem(text = { Text(n) }, onClick = {
|
||||
micSource = i
|
||||
scope.launch { provider.setRadioMicSource(i) }
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
micExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Opus для рации: режим + битрейт ── */
|
||||
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
|
||||
val opusBitrates = listOf(16, 32, 64, 96, 128)
|
||||
var opusMode by remember { mutableStateOf(provider.getInt("radio.opus_mode").coerceIn(0, 1)) }
|
||||
var opusModeExpanded by remember { mutableStateOf(false) }
|
||||
var opusBitrate by remember { mutableStateOf(provider.getInt("radio.opus_bitrate")) }
|
||||
var opusBitrateExpanded by remember { mutableStateOf(false) }
|
||||
Text("Opus (рация)", style = MaterialTheme.typography.titleMedium)
|
||||
ExposedDropdownMenuBox(expanded = opusModeExpanded, onExpandedChange = { opusModeExpanded = it }) {
|
||||
OutlinedTextField(opusModes[opusMode], {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusModeExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
|
||||
ExposedDropdownMenu(opusModeExpanded, onDismissRequest = { opusModeExpanded = false }) {
|
||||
opusModes.forEachIndexed { i, n ->
|
||||
DropdownMenuItem(text = { Text(n) }, onClick = {
|
||||
opusMode = i
|
||||
scope.launch { provider.setValue("radio.opus_mode", i.toString()) }
|
||||
NativeLib.setChatSetting("radio_opus_mode", i.toString())
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
opusModeExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ExposedDropdownMenuBox(expanded = opusBitrateExpanded, onExpandedChange = { opusBitrateExpanded = it }) {
|
||||
OutlinedTextField("${opusBitrate} kbps", {}, readOnly = true, singleLine = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusBitrateExpanded) },
|
||||
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
|
||||
ExposedDropdownMenu(opusBitrateExpanded, onDismissRequest = { opusBitrateExpanded = false }) {
|
||||
opusBitrates.forEach { b ->
|
||||
DropdownMenuItem(text = { Text("$b kbps") }, onClick = {
|
||||
opusBitrate = b
|
||||
scope.launch { provider.setValue("radio.opus_bitrate", b.toString()) }
|
||||
NativeLib.setChatSetting("radio_opus_bitrate", b.toString())
|
||||
RadioAudioEngine.instance().reconfigure()
|
||||
opusBitrateExpanded = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user