Browse Source

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

proxy
evgeny 3 days ago
parent
commit
331970a121
  1. 5
      lib/opus_codec.c
  2. 5
      lib/opus_codec.h
  3. 2
      src/chat/chat_setting.c
  4. 4
      src/config_parser.c
  5. 10
      src/radio/radio_audio.c
  6. 28
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  7. 98
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  8. 13
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  9. 69
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  10. 36
      tools/chatgui-android/jni_bridge/android_jni_bridge.c

5
lib/opus_codec.c

@ -146,6 +146,11 @@ int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type) {
return opus_encoder_ctl(enc->opus_enc, OPUS_SET_SIGNAL(signal_type)); return opus_encoder_ctl(enc->opus_enc, OPUS_SET_SIGNAL(signal_type));
} }
int opus_codec_encoder_application_set(opus_codec_encoder_t* enc, int application) {
if (!enc || !enc->opus_enc) return -1;
return opus_encoder_ctl(enc->opus_enc, OPUS_SET_APPLICATION(application));
}
const char* opus_codec_strerror(int err) { const char* opus_codec_strerror(int err) {
switch (err) { switch (err) {
case OPUS_OK: return "OK"; case OPUS_OK: return "OK";

5
lib/opus_codec.h

@ -10,6 +10,10 @@ extern "C" {
typedef struct opus_codec_encoder opus_codec_encoder_t; typedef struct opus_codec_encoder opus_codec_encoder_t;
typedef struct opus_codec_decoder opus_codec_decoder_t; typedef struct opus_codec_decoder opus_codec_decoder_t;
/* Режим кодирования Opus: голос (VOIP, default) или музыка (AUDIO). */
#define OPUS_CODEC_APP_VOIP 2048 /* OPUS_APPLICATION_VOIP */
#define OPUS_CODEC_APP_AUDIO 2049 /* OPUS_APPLICATION_AUDIO */
opus_codec_encoder_t* opus_codec_encoder_create(int sample_rate, int channels); opus_codec_encoder_t* opus_codec_encoder_create(int sample_rate, int channels);
void opus_codec_encoder_destroy(opus_codec_encoder_t* enc); void opus_codec_encoder_destroy(opus_codec_encoder_t* enc);
int opus_codec_encode(opus_codec_encoder_t* enc, const int16_t* pcm, int frame_samples, int opus_codec_encode(opus_codec_encoder_t* enc, const int16_t* pcm, int frame_samples,
@ -23,6 +27,7 @@ int opus_codec_decode(opus_codec_decoder_t* dec, const uint8_t* data, int len,
int opus_codec_encoder_bitrate_set(opus_codec_encoder_t* enc, int bitrate_bps); int opus_codec_encoder_bitrate_set(opus_codec_encoder_t* enc, int bitrate_bps);
int opus_codec_encoder_complexity_set(opus_codec_encoder_t* enc, int complexity); int opus_codec_encoder_complexity_set(opus_codec_encoder_t* enc, int complexity);
int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type); int opus_codec_encoder_signal_set(opus_codec_encoder_t* enc, int signal_type);
int opus_codec_encoder_application_set(opus_codec_encoder_t* enc, int application);
const char* opus_codec_strerror(int err); const char* opus_codec_strerror(int err);

2
src/chat/chat_setting.c

@ -34,6 +34,8 @@ struct chat_setting_def {
static const struct chat_setting_def g_setting_defs[] = { static const struct chat_setting_def g_setting_defs[] = {
{"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1}, {"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1},
{"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4}, {"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4},
{"radio_opus_mode", CHAT_SETTING_INT, 0, 0, 1},
{"radio_opus_bitrate", CHAT_SETTING_INT, 32, 16, 128},
{"compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1},
{"compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 100}, {"compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 100},
{"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100}, {"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100},

4
src/config_parser.c

@ -1243,7 +1243,7 @@ static struct utun_config* parse_config_internal(FILE *fp, const char *filename)
assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value);
} else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && } else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 &&
chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) {
DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key);
} }
break; break;
case SECTION_GUI: case SECTION_GUI:
@ -1402,7 +1402,7 @@ struct utun_config* parse_config_from_buf(const char *buf, size_t len, const cha
break; break;
case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break; case SECTION_NETWORK: if (cur_network) parse_network(key, value, cur_network, filename, line_num); break;
case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break; case SECTION_NTP: if (strcmp(key, "enabled") == 0) cfg->global.ntp_enabled = strcasecmp(value, "yes") == 0 || strcasecmp(value, "1") == 0 || strcasecmp(value, "true") == 0; else if (strcmp(key, "server") == 0) { struct CFG_NTP_SERVER *ns = u_calloc(1, sizeof(struct CFG_NTP_SERVER)); if (ns) { strncpy(ns->name, value, sizeof(ns->name) - 1); ns->name[sizeof(ns->name) - 1] = '\0'; ns->next = cfg->global.ntp_servers; cfg->global.ntp_servers = ns; cfg->global.ntp_server_count++; } } else if (strcmp(key, "interval") == 0) { cfg->global.ntp_resync_interval = atoi(value); if (cfg->global.ntp_resync_interval < 60) cfg->global.ntp_resync_interval = 60; } else DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown ntp option '%s'", filename, line_num, key); break;
case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); } break; case SECTION_CHATSERVER: cfg->global.chatserver_enabled = 1; cfg->global.db_sync_enabled = 1; if (strcmp(key, "db_path") == 0) assign_string(cfg->global.db_path, sizeof(cfg->global.db_path), value); else if (parse_chatserver(key, value, &cfg->global, filename, line_num) < 0 && chat_setting_set_state(&cfg->global.chat_settings, key, value) < 0) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s:%d: Unknown chatserver option '%s'. Valid: db_path, join_password, allowed_groups, storage_total_size, storage_unit_size, storage_autoload, opus_codec_preset, radio_opus_mode, radio_opus_bitrate, compressor_enabled, compressor_max_gain_db, compressor_rise_rate, media_download_max_peers", filename, line_num, key); } break;
case SECTION_GUI: break; case SECTION_GUI: break;
case SECTION_REALITY: parse_reality(key, value, &cfg->global.reality, filename, line_num); break; case SECTION_REALITY: parse_reality(key, value, &cfg->global.reality, filename, line_num); break;
case SECTION_SOCKS: parse_socks(key, value, &cfg->global, filename, line_num); break; case SECTION_SOCKS: parse_socks(key, value, &cfg->global, filename, line_num); break;

10
src/radio/radio_audio.c

@ -264,7 +264,10 @@ int radio_audio_start(uint64_t group_id) {
opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1); opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, 1);
if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; }
opus_codec_encoder_bitrate_set(enc, RADIO_AUDIO_BITRATE); int opus_mode = chat_setting_get_int(g_inst, "radio_opus_mode", 0);
int opus_bitrate_kbps = chat_setting_get_int(g_inst, "radio_opus_bitrate", RADIO_AUDIO_BITRATE / 1000);
opus_codec_encoder_application_set(enc, opus_mode ? OPUS_CODEC_APP_AUDIO : OPUS_CODEC_APP_VOIP);
opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000);
/* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста. /* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста.
* gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */ * gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */
@ -290,8 +293,9 @@ int radio_audio_start(uint64_t group_id) {
memset(g_sources, 0, sizeof(g_sources)); memset(g_sources, 0, sizeof(g_sources));
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx compressor=%s", RADIO_AUDIO_ID, DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx opus_mode=%s bitrate=%dkbps compressor=%s", RADIO_AUDIO_ID,
(unsigned long long)group_id, ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable"); (unsigned long long)group_id, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps,
ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable");
return 0; return 0;
} }

28
tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt

@ -56,6 +56,9 @@ class ConfigProvider(private val context: Context) {
private val callAudioRouteKey = stringPreferencesKey("call_audio_route") private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay") 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 { private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath 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_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000 key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1 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 else -> 0
} }
} }
@ -166,6 +171,8 @@ class ConfigProvider(private val context: Context) {
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15 "chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000 "chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 "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 } 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 ── */ /* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> { 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 chatAutoloadMaxMb = getInt("chat.storage_autoload_maxsize_mb")
val chatMaxGb = getInt("chat.storage_maxsize_gb") val chatMaxGb = getInt("chat.storage_maxsize_gb")
val chatOpusPreset = getInt("chat.opus_codec_preset") 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 chatCompEnabled = getInt("chat.compressor_enabled")
val chatCompMaxGain = getInt("chat.compressor_max_gain_db") val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
val chatCompRiseRate = getInt("chat.compressor_rise_rate") 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_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb") appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset") appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("radio_opus_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("compressor_enabled=$chatCompEnabled") appendLine("compressor_enabled=$chatCompEnabled")
appendLine("compressor_max_gain_db=$chatCompMaxGain") appendLine("compressor_max_gain_db=$chatCompMaxGain")
appendLine("compressor_rise_rate=$chatCompRiseRate") appendLine("compressor_rise_rate=$chatCompRiseRate")

98
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -7,6 +7,9 @@ import android.media.AudioManager
import android.media.AudioRecord import android.media.AudioRecord
import android.media.AudioTrack import android.media.AudioTrack
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build
import android.os.Handler
import android.os.Looper
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -29,6 +32,9 @@ class RadioAudioEngine {
const val FRAME_MS = 20 const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */
const val RELEASE_HOLD_MS = 200L
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ /** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
@Volatile private var inst: RadioAudioEngine? = null @Volatile private var inst: RadioAudioEngine? = null
fun instance(): RadioAudioEngine = inst ?: synchronized(this) { fun instance(): RadioAudioEngine = inst ?: synchronized(this) {
@ -56,6 +62,10 @@ class RadioAudioEngine {
Thread(r, "radio-audio-io").apply { isDaemon = true } 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 isActive(): Boolean = active
fun isTalking(): Boolean = talking fun isTalking(): Boolean = talking
@ -75,6 +85,7 @@ class RadioAudioEngine {
active = false active = false
talking = false talking = false
_talkingFlow.value = false _talkingFlow.value = false
mainHandler.removeCallbacks(releaseRunnable)
io.execute { stopBlocking() } io.execute { stopBlocking() }
} }
@ -88,7 +99,25 @@ class RadioAudioEngine {
active = false active = false
releaseLocked(false) releaseLocked(false)
active = true 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)) { if (!NativeLib.radioAudioStart(group)) {
active = false active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
@ -109,18 +138,8 @@ class RadioAudioEngine {
// ── AudioRecord (захват) ── // ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize( val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val rec = try { val rec = createAudioRecord((recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
AudioRecord( if (rec == null) {
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()
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
} }
@ -159,6 +178,40 @@ class RadioAudioEngine {
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") 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() { private fun stopBlocking() {
releaseLocked(true) releaseLocked(true)
} }
@ -184,7 +237,9 @@ class RadioAudioEngine {
/** PTT зажата — начинаем передачу. */ /** PTT зажата — начинаем передачу. */
fun talkBegin() { fun talkBegin() {
if (!active || talking) return if (!active) return
mainHandler.removeCallbacks(releaseRunnable)
if (talking) return
if (micDead) { if (micDead) {
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)") LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)")
} }
@ -193,12 +248,27 @@ class RadioAudioEngine {
NativeLib.radioTalkBegin(groupId) NativeLib.radioTalkBegin(groupId)
} }
/** PTT отпущена — завершаем передачу (FIN). */ /** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */
fun talkEnd() { fun talkEnd() {
if (!active || !talking) return 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 talking = false
_talkingFlow.value = false _talkingFlow.value = false
NativeLib.radioTalkEnd(groupId) NativeLib.radioTalkEnd(groupId)
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)")
} }
private fun captureLoop() { private fun captureLoop() {

13
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt

@ -185,7 +185,7 @@ class RadioOverlayService : Service() {
dragAllowed = false dragAllowed = false
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) { } else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
dragging = true dragging = true
endTalk() endTalkImmediate()
lp.x = startX + (event.rawX - touchX).toInt() lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt() lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp) wm.updateViewLayout(v, lp)
@ -216,10 +216,19 @@ class RadioOverlayService : Service() {
} }
private fun endTalk() { private fun endTalk() {
endTalkInternal(immediate = false)
}
private fun endTalkImmediate() {
endTalkInternal(immediate = true)
}
private fun endTalkInternal(immediate: Boolean) {
if (!talking) return if (!talking) return
talking = false talking = false
updateButtonColor() updateButtonColor()
RadioAudioEngine.instance().talkEnd() if (immediate) RadioAudioEngine.instance().talkEndImmediate()
else RadioAudioEngine.instance().talkEnd()
} }
private fun updateButtonColor() { private fun updateButtonColor() {

69
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt

@ -471,6 +471,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
/* ── Radio Settings Tab ── */ /* ── Radio Settings Tab ── */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun RadioSettingsTab() { private fun RadioSettingsTab() {
val provider = ChatApplication.instance.configProvider val provider = ChatApplication.instance.configProvider
@ -528,6 +529,74 @@ private fun RadioSettingsTab() {
RadioOverlayService.stop(ChatApplication.instance) 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
})
}
}
}
} }
} }

36
tools/chatgui-android/jni_bridge/android_jni_bridge.c

@ -710,11 +710,11 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
if (member_count == 0) return u_strdup("[]"); if (member_count == 0) return u_strdup("[]");
snprintf(sql, sizeof(sql), 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.local_nick,''), COALESCE(p.adm_tags,''),"
" COALESCE(p.node_type,0), COALESCE(p.node_RTT,0), COALESCE(p.storage,0)" " 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" " 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; sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { 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) { while (sqlite3_step(st) == SQLITE_ROW) {
uint64_t node_id = (uint64_t)sqlite3_column_int64(st, 0); 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, 1);
const char* name = (const char*)sqlite3_column_text(st, 2); const char* local_nick = (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, 3);
const char* adm_tags = (const char*)sqlite3_column_text(st, 4); int node_type = sqlite3_column_int(st, 4);
int node_type = sqlite3_column_int(st, 5); int node_rtt = sqlite3_column_int(st, 5);
int node_rtt = sqlite3_column_int(st, 6); int storage_flag = sqlite3_column_int(st, 6);
int storage_flag = sqlite3_column_int(st, 7);
int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = 0; int is_admin = 0, is_super = 0, is_moder = 0, is_verified = 0, is_deleted = 0;
if (adm_tags) { if (adm_tags) {
@ -763,18 +762,18 @@ char* utun_bridge_get_channel_members_json(const char* channel_id) {
first = 0; first = 0;
size_t needed = snprintf(NULL, 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}", "\"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_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0); 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; } while (pos + needed + 2 > cap) { char* tmp = u_realloc(json, cap * 2); if (!tmp) break; json = tmp; cap *= 2; }
if (pos + needed + 2 <= cap) if (pos + needed + 2 <= cap)
pos += (size_t)snprintf(json + pos, cap - pos, 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}", "\"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_admin, is_super, is_moder, storage_flag,
is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0); is_verified, is_deleted, node_type, node_rtt, (node_id == my_id) ? 1 : 0);
u_free(esc_name); 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; sqlite3_stmt* st = NULL;
if (sqlite3_prepare_v2(db, if (sqlite3_prepare_v2(db,
"SELECT COALESCE(name,''), COALESCE(online,0)," "SELECT COALESCE(name,''),"
" COALESCE(last_seen_at,0), COALESCE(created_at,0)" " COALESCE(last_seen_at,0), COALESCE(created_at,0)"
" FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) { " FROM nodes WHERE node_id=?", -1, &st, NULL) == SQLITE_OK) {
sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id); sqlite3_bind_int64(st, 1, (sqlite3_int64)node_id);
if (sqlite3_step(st) == SQLITE_ROW) { if (sqlite3_step(st) == SQLITE_ROW) {
const char* name = (const char*)sqlite3_column_text(st, 0); 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, 1);
int64_t last_seen = sqlite3_column_int64(st, 2); int64_t created = sqlite3_column_int64(st, 2);
int64_t created = sqlite3_column_int64(st, 3);
char* esc_name = json_escape_alloc(name && name[0] ? name : ""); char* esc_name = json_escape_alloc(name && name[0] ? name : "");
pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos, pos += (size_t)snprintf(buf + pos, sizeof(buf) - pos,
",\"n\":\"%s\",\"on\":%d,\"ls\":%lld,\"cr\":%lld", ",\"n\":\"%s\",\"on\":0,\"ls\":%lld,\"cr\":%lld",
esc_name, online, (long long)last_seen, (long long)created); esc_name, (long long)last_seen, (long long)created);
u_free(esc_name); u_free(esc_name);
} }
sqlite3_finalize(st); sqlite3_finalize(st);

Loading…
Cancel
Save