Radio: Opus mode/bitrate + mic source + PTT release tail; drop online status from member JSON

This commit is contained in:
evgeny committed 2026-09-29 00:40:10 +03:00
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
})
}
}
}
}
}
@@ -710,11 +710,11 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
if (member_count == 0) return u_strdup("[]");
snprintf(sql, sizeof(sql),
"SELECT p.node_id, COALESCE(n.online,0), COALESCE(n.name,''),"
"SELECT p.node_id, COALESCE(n.name,''),"
" COALESCE(p.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)"
" FROM \"%s\" p LEFT JOIN nodes n ON p.node_id=n.node_id"
" ORDER BY n.online DESC, p.node_id ASC", peers_tbl);
" ORDER BY p.node_id ASC", peers_tbl);
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
@@ -733,13 +733,12 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
while (sqlite3_step(st) == SQLITE_ROW) {
uint64_t node_id = (uint64_t)sqlite3_column_int64(st, 0);
int online = sqlite3_column_int(st, 1);
const char* name = (const char*)sqlite3_column_text(st, 2);
const char* local_nick = (const char*)sqlite3_column_text(st, 3);
const char* adm_tags = (const char*)sqlite3_column_text(st, 4);
int node_type = sqlite3_column_int(st, 5);
int node_rtt = sqlite3_column_int(st, 6);
int storage_flag = sqlite3_column_int(st, 7);
const char* name = (const char*)sqlite3_column_text(st, 1);
const char* local_nick = (const char*)sqlite3_column_text(st, 2);
const char* adm_tags = (const char*)sqlite3_column_text(st, 3);
int node_type = sqlite3_column_int(st, 4);
int node_rtt = sqlite3_column_int(st, 5);
int storage_flag = sqlite3_column_int(st, 6);
int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = 0;
if (adm_tags) {
@@ -763,18 +762,18 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
first = 0;
size_t needed = snprintf(NULL, 0,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}",
sep, esc_name, (unsigned long long)node_id, online,
sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0);
while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos,
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":%d,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"%s{\"n\":\"%s\",\"ni\":%llu,\"on\":0,\"ad\":%d,\"sp\":%d,\"md\":%d,\"st\":%d,"
"\"vd\":%d,\"dl\":%d,\"tp\":%d,\"rt\":%d,\"is\":%d}",
sep, esc_name, (unsigned long long)node_id, online,
sep, esc_name, (unsigned long long)node_id,
is_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0);
u_free(esc_name);
@@ -796,19 +795,18 @@ char* utun_bridge_get_member_detail_json(const char* channel_id, uint64_t node_i
sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db,
"SELECT COALESCE(name,''), COALESCE(online,0),"
"SELECT COALESCE(name,''),"
" COALESCE(last_seen_at,0), COALESCE(created_at,0)"
" FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
if (sqlite3_step(st) == SQLITE_ROW) {
const char* name = (const char*)sqlite3_column_text(st, 0);
int online = sqlite3_column_int(st, 1);
int64_t last_seen = sqlite3_column_int64(st, 2);
int64_t created = sqlite3_column_int64(st, 3);
int64_t last_seen = sqlite3_column_int64(st, 1);
int64_t created = sqlite3_column_int64(st, 2);
char* esc_name = json_escape_alloc(name && name[0] ? name : "");
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos,
",\"n\":\"%s\",\"on\":%d,\"ls\":%lld,\"cr\":%lld",
esc_name, online, (long long)last_seen, (long long)created);
",\"n\":\"%s\",\"on\":0,\"ls\":%lld,\"cr\":%lld",
esc_name, (long long)last_seen, (long long)created);
u_free(esc_name);
}
sqlite3_finalize(st);