diff --git a/lib/audio_compressor.c b/lib/audio_compressor.c index b24d907f..135093ac 100644 --- a/lib/audio_compressor.c +++ b/lib/audio_compressor.c @@ -16,6 +16,7 @@ struct audio_compressor { int lookback_blocks; int lookahead_blocks; float rise_factor_per_block; + float release_factor_per_block; float max_gain; float gain_smoothed; @@ -38,6 +39,7 @@ struct audio_compressor { size_t output_size; size_t output_cap; + uint32_t clip_count; int dbg_counter; }; @@ -79,13 +81,16 @@ void audio_compressor_configure(struct audio_compressor* ac, const audio_compres ac->lookback_blocks = ac->cfg.lookback_ms / ac->cfg.block_duration_ms; ac->lookahead_blocks = ac->cfg.lookahead_ms / ac->cfg.block_duration_ms; ac->rise_factor_per_block = powf(ac->cfg.rise_rate_per_sec, (float)ac->cfg.block_duration_ms / 1000.0f); + if (ac->cfg.release_rate_per_sec <= 0.0f) + ac->cfg.release_rate_per_sec = ac->cfg.rise_rate_per_sec * 5.0f; + ac->release_factor_per_block = powf(ac->cfg.release_rate_per_sec, (float)ac->cfg.block_duration_ms / 1000.0f); ac->max_gain = powf(10.0f, ac->cfg.max_gain_db / 20.0f); DEBUG_INFO(DEBUG_CATEGORY_GENERAL, - "audio_compressor_configure: rate=%d ch=%d block=%dms lookback=%dms lookahead=%dms maxGain=%.0fdB riseRate=%.1f/sec target=%.0fdBFS", + "audio_compressor_configure: rate=%d ch=%d block=%dms lookback=%dms lookahead=%dms maxGain=%.0fdB riseRate=%.1f/sec releaseRate=%.1f/sec target=%.0fdBFS", ac->cfg.sample_rate, ac->cfg.channels, ac->cfg.block_duration_ms, ac->cfg.lookback_ms, ac->cfg.lookahead_ms, ac->cfg.max_gain_db, - ac->cfg.rise_rate_per_sec, 20.0f * log10f(ac->cfg.target_level)); + ac->cfg.rise_rate_per_sec, ac->cfg.release_rate_per_sec, 20.0f * log10f(ac->cfg.target_level)); } void audio_compressor_reset(struct audio_compressor* ac) { @@ -97,6 +102,7 @@ void audio_compressor_reset(struct audio_compressor* ac) { ac->accum_count = 0; ac->output_size = 0; ac->gain_smoothed = 1.0f; + ac->clip_count = 0; ac->dbg_counter = 0; } @@ -227,25 +233,29 @@ static void process_pending_block(struct audio_compressor* ac) { float G_prev = ac->gain_smoothed; if (G_raw > ac->gain_smoothed) { + /* сигнал стал тише — плавно поднимаем gain (атака) */ float max_rise = ac->gain_smoothed * ac->rise_factor_per_block; ac->gain_smoothed = G_raw < max_rise ? G_raw : max_rise; } else { - ac->gain_smoothed = G_raw; + /* сигнал стал громче — плавно опускаем gain (release), без мгновенной ступеньки + * (мгновенный дроп давал клик на границе 20мс-блока). */ + float min_fall = ac->gain_smoothed / ac->release_factor_per_block; + ac->gain_smoothed = G_raw > min_fall ? G_raw : min_fall; } if (ac->gain_smoothed > ac->max_gain) ac->gain_smoothed = ac->max_gain; float gain_db = 20.0f * log10f(ac->gain_smoothed > 0.0001f ? ac->gain_smoothed : 0.0001f); float prev_db = 20.0f * log10f(G_prev > 0.0001f ? G_prev : 0.0001f); if (ac->dbg_counter % 25 == 0 || fabsf(gain_db - prev_db) > 3.0f) { - DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "audio_compressor: block %zu level=%.4f envelope=%.4f gainRaw=%.1fdB gain=%.1fdB", - idx, ac->block_levels[idx], envelope, 20.0f * log10f(G_raw), gain_db); + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "audio_compressor: block %zu level=%.4f envelope=%.4f gainRaw=%.1fdB gain=%.1fdB clip=%u", + idx, ac->block_levels[idx], envelope, 20.0f * log10f(G_raw), gain_db, (unsigned)ac->clip_count); } ac->dbg_counter++; for (size_t i = 0; i < ac->pending[0].count; i++) { float v = (float)ac->pending[0].samples[i] * ac->gain_smoothed; - if (v > 32767.0f) v = 32767.0f; - if (v < -32768.0f) v = -32768.0f; + if (v > 32767.0f) { v = 32767.0f; ac->clip_count++; } + if (v < -32768.0f) { v = -32768.0f; ac->clip_count++; } ac->pending[0].samples[i] = (int16_t)(int)v; } append_output(ac, ac->pending[0].samples, ac->pending[0].count); @@ -295,3 +305,7 @@ void audio_compressor_clear_output(struct audio_compressor* ac) { if (!ac) return; ac->output_size = 0; } + +uint32_t audio_compressor_clip_count(const struct audio_compressor* ac) { + return ac ? ac->clip_count : 0; +} diff --git a/lib/audio_compressor.h b/lib/audio_compressor.h index 198eb6f7..c77900e2 100644 --- a/lib/audio_compressor.h +++ b/lib/audio_compressor.h @@ -18,6 +18,7 @@ typedef struct { int lookahead_ms; float max_gain_db; float rise_rate_per_sec; + float release_rate_per_sec; /* скорость плавного снижения gain (0 = авто: rise*5) */ float target_level; } audio_compressor_config_t; @@ -40,6 +41,9 @@ size_t audio_compressor_output_size(const struct audio_compressor* ac); * НЕ трогает gain_smoothed (адаптивная громкость сохраняется). */ void audio_compressor_clear_output(struct audio_compressor* ac); +/* Число сэмплов, обрезанных по потолку int16 (клиппинг) с момента create/reset. */ +uint32_t audio_compressor_clip_count(const struct audio_compressor* ac); + #ifdef __cplusplus } #endif diff --git a/lib/u_async.c b/lib/u_async.c index 6bb840be..5d8465f7 100644 --- a/lib/u_async.c +++ b/lib/u_async.c @@ -1,5 +1,10 @@ // uasync.c +// CLOCK_BOOTTIME (Linux) требует _GNU_SOURCE в glibc. +#ifndef _GNU_SOURCE +#define _GNU_SOURCE 1 +#endif + #include "u_async.h" #include "platform_compat.h" #include "debug_config.h" @@ -362,7 +367,11 @@ uint64_t get_time_tb(void) { #else uint64_t get_time_tb(void) { struct timespec ts; +#ifdef __linux__ + clock_gettime(CLOCK_BOOTTIME, &ts); +#else clock_gettime(CLOCK_MONOTONIC, &ts); +#endif return (uint64_t)ts.tv_sec * 10000ULL + (uint64_t)ts.tv_nsec / 100000ULL; // Преобразуем в требуемые единицы времени } #endif @@ -377,7 +386,11 @@ uint64_t get_time_us(void) { #else uint64_t get_time_us(void) { struct timespec ts; +#ifdef __linux__ + clock_gettime(CLOCK_BOOTTIME, &ts); +#else clock_gettime(CLOCK_MONOTONIC, &ts); +#endif return (uint64_t)ts.tv_sec * 1000000ULL + (uint64_t)ts.tv_nsec / 1000ULL; } #endif diff --git a/src/call/voice_jitter.cpp b/src/call/voice_jitter.cpp index 7ef551b8..45ddd362 100644 --- a/src/call/voice_jitter.cpp +++ b/src/call/voice_jitter.cpp @@ -66,15 +66,20 @@ struct vj { std::atomic tempo_x100{100}; }; -/* Симметричный целевой темп: глубина < target → min_tempo..1.0 (накопление), - * глубина > target → 1.0..max_tempo (догон). */ +/* Симметричный целевой темп с мёртвой зоной ±25% вокруг target: внутри зоны темп=1.0 + * (без time-stretch, меньше артефактов), за её пределами — плавно к min/max. */ static double vj_compute_tempo(double depth_ms, double target_ms, double min_tempo, double max_tempo) { - if (depth_ms <= target_ms) { - double x = depth_ms / target_ms; /* 0 → min, 1 → 1.0 */ + double dead = target_ms * 0.25; + double lo = target_ms - dead; + double hi = target_ms + dead; + if (depth_ms >= lo && depth_ms <= hi) return 1.0; + + if (depth_ms < lo) { + double x = depth_ms / lo; /* 0 → min, lo → 1.0 */ if (x < 0.0) x = 0.0; return min_tempo + (1.0 - min_tempo) * x; } - double x = (depth_ms - target_ms) / (kMaxDepthMs - target_ms); + double x = (depth_ms - hi) / (kMaxDepthMs - hi); /* hi → 1.0, kMaxDepth → max */ if (x > 1.0) x = 1.0; return 1.0 + (max_tempo - 1.0) * x; } @@ -235,8 +240,10 @@ void vj_end(struct vj* j) { void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns) { if (!j) return; if (depth_ms) { + /* полная глубина = кодированные кадры в кольце + невыданный PCM внутри SoundTouch */ std::lock_guard lk(j->mtx); - *depth_ms = j->count * j->frame_ms; + double st_ms = (double)j->st.numSamples() * 1000.0 / (double)j->sr; + *depth_ms = (int)((double)j->count * j->frame_ms + st_ms + 0.5); } if (tempo_x100) *tempo_x100 = j->tempo_x100.load(); if (dropped) *dropped = j->dropped; diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 2dbd4a4d..e3553fb9 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -38,7 +38,7 @@ #define RADIO_AUDIO_STATS_TB 10000 /* 1с: период лога статистики буфера */ /* Параметры джиттер-буфера рации (адаптивный, симметричный темп + pre-roll). */ -#define RADIO_AUDIO_TARGET_MS 80.0 /* целевая глубина */ +#define RADIO_AUDIO_TARGET_MS 120.0 /* целевая глубина (запас под clock drift) */ #define RADIO_AUDIO_MIN_TEMPO 0.75 /* темп при пустом буфере (замедление) */ #define RADIO_AUDIO_MAX_TEMPO 1.6 /* темп при глубоком буфере (догон) */ @@ -75,6 +75,12 @@ static struct radio_source g_sources[RADIO_AUDIO_MAX_SOURCES]; static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES]; static uint64_t g_last_stats_tb = 0; +/* TX-статистика (кадрирование/кодирование), выводится раз в секунду пока идёт PTT. */ +static uint64_t g_tx_stats_tb = 0; +static uint32_t g_tx_frames = 0; /* кадров за интервал */ +static uint32_t g_tx_bytes = 0; /* суммарный размер opus за интервал */ +static uint32_t g_tx_tail = 0; /* дропнутых хвостовых сэмплов за интервал */ + /* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { opus_codec_decoder_t* dec = (opus_codec_decoder_t*)arg; @@ -376,9 +382,14 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { if (l > 0) { opus_len[n_frames] = l; n_frames++; } off += RADIO_AUDIO_FRAME_SAMPLES; } - if (off != src_count) + if (off != src_count) { + g_tx_tail += (uint32_t)(src_count - off); DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: feed_pcm dropped %zu tail samples (compressor)", RADIO_AUDIO_ID, src_count - off); + } if (g_compressor) audio_compressor_clear_output(g_compressor); + g_tx_frames += (uint32_t)n_frames; + for (int i = 0; i < n_frames; i++) g_tx_bytes += (uint32_t)opus_len[i]; + uint32_t clip = g_compressor ? audio_compressor_clip_count(g_compressor) : 0; ua = g_inst ? g_inst->ua : NULL; inst = g_inst; @@ -390,6 +401,19 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { return -1; } + /* каденс TX: раз в секунду — кадров/с, средний размер opus-кадра, хвост, клиппинг */ + uint64_t now = get_time_tb(); + if (g_tx_stats_tb == 0 || now - g_tx_stats_tb >= RADIO_AUDIO_STATS_TB) { + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: tx stats frames=%u/s avg=%.0fB/frame tail=%u clip=%u", + RADIO_AUDIO_ID, g_tx_frames, + g_tx_frames ? (double)g_tx_bytes / (double)g_tx_frames : 0.0, + (unsigned)g_tx_tail, (unsigned)clip); + g_tx_stats_tb = now; + g_tx_frames = 0; + g_tx_bytes = 0; + g_tx_tail = 0; + } + for (int i = 0; i < n_frames; i++) { struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); if (!a) return -1; @@ -488,9 +512,9 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { if (do_stats) { g_last_stats_tb = now; for (int i = 0; i < nst; i++) - DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: stats src=%016llx stream=%u depth=%dms tempo=%u.%02u dropped=%u underruns=%u reorder=%d", - RADIO_AUDIO_ID, (unsigned long long)st[i].src, st[i].stream, st[i].depth, - st[i].tempo / 100, st[i].tempo % 100, (unsigned)st[i].dropped, (unsigned)st[i].under, st[i].reorder); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: rx stats src=%016llx stream=%u depth=%dms tempo=%u.%02u dropped=%u underruns=%u reorder=%d", + RADIO_AUDIO_ID, (unsigned long long)st[i].src, st[i].stream, st[i].depth, + st[i].tempo / 100, st[i].tempo % 100, (unsigned)st[i].dropped, (unsigned)st[i].under, st[i].reorder); } /* насыщающее суммирование (soft-clip) в int16 */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 7ecdae7c..f6376708 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -263,19 +263,39 @@ class ConfigProvider(private val context: Context) { /* ── Рация: volume-кнопки PTT и оверлей ── */ - fun getRadioVolumePtt(): Boolean = runBlocking { - context.dataStore.data.first()[radioVolumePttKey] ?: false + /* Кэш быстрых настроек рации: чтение не блокирует главный поток. + * Ленивая загрузка один раз, дальше — из памяти; set обновляет кэш сразу. */ + @Volatile private var radioVolumePttCached = false + @Volatile private var radioVolumePttLoaded = false + + fun getRadioVolumePtt(): Boolean { + if (!radioVolumePttLoaded) { + radioVolumePttCached = runBlocking { context.dataStore.data.first()[radioVolumePttKey] ?: false } + radioVolumePttLoaded = true + } + return radioVolumePttCached } suspend fun setRadioVolumePtt(enabled: Boolean) { + radioVolumePttCached = enabled + radioVolumePttLoaded = true context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled } } - fun getRadioOverlay(): Boolean = runBlocking { - context.dataStore.data.first()[radioOverlayKey] ?: false + @Volatile private var radioOverlayCached = false + @Volatile private var radioOverlayLoaded = false + + fun getRadioOverlay(): Boolean { + if (!radioOverlayLoaded) { + radioOverlayCached = runBlocking { context.dataStore.data.first()[radioOverlayKey] ?: false } + radioOverlayLoaded = true + } + return radioOverlayCached } suspend fun setRadioOverlay(enabled: Boolean) { + radioOverlayCached = enabled + radioOverlayLoaded = true context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index f99f9020..86f07b23 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -10,6 +10,7 @@ import android.media.MediaRecorder import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import java.util.concurrent.Executors /** * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). @@ -35,9 +36,9 @@ class RadioAudioEngine { } } - private var audioRecord: AudioRecord? = null + @Volatile private var audioRecord: AudioRecord? = null private var captureThread: Thread? = null - private var audioTrack: AudioTrack? = null + @Volatile private var audioTrack: AudioTrack? = null private var playThread: Thread? = null private var prevMode: Int = AudioManager.MODE_NORMAL private var prevSpeaker: Boolean = false @@ -50,22 +51,45 @@ class RadioAudioEngine { private val _talkingFlow = MutableStateFlow(false) val talkingFlow: StateFlow = _talkingFlow + /* Сериализованный поток для setup/teardown аудио (не блокирует main). */ + private val io = Executors.newSingleThreadExecutor { r -> + Thread(r, "radio-audio-io").apply { isDaemon = true } + } + fun isActive(): Boolean = active fun isTalking(): Boolean = talking - /** Начать прослушивание канала (переключатель рации вкл). */ - fun start(group: Long): Boolean { - stop() + /** Начать прослушивание канала (переключатель рации вкл). + * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ + fun start(group: Long) { + if (active && groupId == group) return + active = true + groupId = group + talking = false + io.execute { startBlocking(group) } + } + + fun stop() { + if (!active) return + active = false + talking = false + _talkingFlow.value = false + io.execute { stopBlocking() } + } + + /* ── io-поток (сериализовано): setup/teardown аудио ── */ + + private fun startBlocking(group: Long) { + if (!active || groupId != group) return + releaseLocked(false) if (!NativeLib.radioAudioStart(group)) { + active = false LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") - return false + return } - groupId = group - talking = false - val ctx = ChatApplication.instance val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager prevMode = am.mode @@ -80,26 +104,26 @@ class RadioAudioEngine { // ── AudioRecord (захват) ── val recMin = AudioRecord.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) - audioRecord = try { + 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(); return false + NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return } - if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + if (rec.state != AudioRecord.STATE_INITIALIZED) { LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized") - audioRecord?.release(); audioRecord = null - NativeLib.radioAudioStop(); restoreAudioMode(); return false + rec.release() + NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return } // ── AudioTrack (воспроизведение) ── val trackMin = AudioTrack.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) - audioTrack = try { + val track = try { AudioTrack( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) @@ -110,15 +134,15 @@ class RadioAudioEngine { trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") - audioRecord?.release(); audioRecord = null - audioTrack = null - NativeLib.radioAudioStop(); restoreAudioMode(); return false + rec.release() + NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return } - active = true + audioRecord = rec + audioTrack = track micDead = false - audioRecord?.startRecording() - audioTrack?.play() + rec.startRecording() + track.play() captureThread = Thread { captureLoop() }.apply { priority = Thread.MAX_PRIORITY; start() @@ -128,15 +152,14 @@ class RadioAudioEngine { } LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") - return true } - fun stop() { - if (!active) return - active = false - talking = false - _talkingFlow.value = false + private fun stopBlocking() { + releaseLocked(true) + } + /* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */ + private fun releaseLocked(resetGroup: Boolean) { captureThread?.join(1000); captureThread = null playThread?.join(1000); playThread = null @@ -148,8 +171,10 @@ class RadioAudioEngine { NativeLib.radioAudioStop() restoreAudioMode() - groupId = 0L - LogManager.addLog("INFO", "RadioAudio", "stopped") + if (resetGroup) { + groupId = 0L + LogManager.addLog("INFO", "RadioAudio", "stopped") + } } /** PTT зажата — начинаем передачу. */ @@ -205,16 +230,22 @@ class RadioAudioEngine { private fun playLoop() { val buf = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES) + val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */ + var nextNs = System.nanoTime() while (active) { - val t0 = System.nanoTime() val track = audioTrack ?: return val n = NativeLib.radioAudioPull(groupId, buf) if (n > 0) track.write(buf, 0, n) if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) - val elapsedMs = (System.nanoTime() - t0) / 1_000_000 - val sleepMs = FRAME_MS - elapsedMs - if (sleepMs > 0) { - try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return } + + /* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф */ + nextNs += frameNs + val waitNs = nextNs - System.nanoTime() + if (waitNs > 0) { + try { Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt()) } + catch (_: InterruptedException) { return } + } else { + nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */ } } LogManager.addLog("DEBUG", "RadioAudio", "play loop done") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt index 910b5590..b64cbae6 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt @@ -45,16 +45,26 @@ class RadioOverlayService : Service() { private val _isShown = MutableStateFlow(false) val isShown: StateFlow = _isShown + /* Гонка start/stop: stop() до вызова startForeground() роняет FGS. + * Пока сервис стартует (starting) — взводим pendingStop, а onCreate + * после startForeground() сам вызовет stopSelf(). */ + @Volatile private var starting = false + @Volatile private var pendingStop = false + fun canDrawOverlays(ctx: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) fun start(ctx: Context) { + LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}") + pendingStop = false if (Build.VERSION.SDK_INT >= 34 && ContextCompat.checkSelfPermission(ctx, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) != PackageManager.PERMISSION_GRANTED) { LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted") return } + if (starting || _isShown.value) return + starting = true val i = Intent(ctx, RadioOverlayService::class.java) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) @@ -63,6 +73,11 @@ class RadioOverlayService : Service() { } fun stop(ctx: Context) { + LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.stop isShown=${_isShown.value} t=${System.currentTimeMillis()}") + if (starting) { + pendingStop = true + return + } ctx.stopService(Intent(ctx, RadioOverlayService::class.java)) } } @@ -76,6 +91,7 @@ class RadioOverlayService : Service() { override fun onCreate() { super.onCreate() + LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.onCreate thread=${Thread.currentThread().name} t=${System.currentTimeMillis()}") createChannel() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(), @@ -87,10 +103,16 @@ class RadioOverlayService : Service() { windowManager = getSystemService(WINDOW_SERVICE) as WindowManager if (!canDrawOverlays(this)) { LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping") + starting = false stopSelf() return } addOverlay() + starting = false + if (pendingStop) { + pendingStop = false + stopSelf() + } } private fun createChannel() { @@ -212,6 +234,7 @@ class RadioOverlayService : Service() { endTalk() overlayView?.let { windowManager?.removeView(it) } overlayView = null + starting = false _isShown.value = false LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") super.onDestroy() diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index 4d2b8bf3..8041d413 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -65,6 +65,8 @@ class ChatViewModel : ViewModel() { private val _radioTalking = MutableStateFlow(null) val radioTalking: StateFlow = _radioTalking + private var radioToggleCount = 0 + private var callStartedMs = 0L private var callIncomingAtMs = 0L @@ -732,6 +734,12 @@ class ChatViewModel : ViewModel() { val ch = _currentChannel.value ?: return val groupId = ch.id.toULongOrNull()?.toLong() ?: return val next = !_radioActive.value + radioToggleCount++ + val caller = Throwable().stackTrace.drop(1).take(8) + .joinToString(" <- ") { "${it.className.substringAfterLast('.')}.${it.methodName}:${it.lineNumber}" } + LogManager.addLog("DEBUG", "RADIO_DBG", + "toggleRadio#$radioToggleCount next=$next prevActive=${_radioActive.value} " + + "thread=${Thread.currentThread().name} t=${System.currentTimeMillis()} caller=$caller") NativeLib.radioSetActive(groupId, next) _radioActive.value = next if (next) { @@ -766,7 +774,11 @@ class ChatViewModel : ViewModel() { private fun syncRadioOverlay() { val ctx = ChatApplication.instance - if (_radioActive.value && ctx.configProvider.getRadioOverlay()) + val overlayEnabled = ctx.configProvider.getRadioOverlay() + LogManager.addLog("DEBUG", "RADIO_DBG", + "syncRadioOverlay active=${_radioActive.value} overlayEnabled=$overlayEnabled " + + "isShown=${RadioOverlayService.isShown.value} t=${System.currentTimeMillis()}") + if (_radioActive.value && overlayEnabled) RadioOverlayService.start(ctx) else RadioOverlayService.stop(ctx) diff --git a/tools/chatgui/tests/test_radio_audio.cpp b/tools/chatgui/tests/test_radio_audio.cpp index 3c2c964b..3ee35887 100644 --- a/tools/chatgui/tests/test_radio_audio.cpp +++ b/tools/chatgui/tests/test_radio_audio.cpp @@ -103,50 +103,50 @@ int main() { TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); { radio_audio_start(GID); - push_const(SRC_A, 1, 10000, 5); - int mx = pull_max_abs(10); + push_const(SRC_A, 1, 10000, 8); + int mx = pull_max_abs(16); radio_audio_stop(); if (mx > 1000) OK(); else FAIL("max|pcm|=%d (ожидали > 1000)", mx); } TEST("mixing: два источника складываются (soft-clip)"); { radio_audio_start(GID); - push_const(SRC_A, 1, 20000, 5); - push_const(SRC_B, 1, 20000, 5); - int mx = pull_max_abs(10); + push_const(SRC_A, 1, 20000, 8); + push_const(SRC_B, 1, 20000, 8); + int mx = pull_max_abs(16); radio_audio_stop(); if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx); } - TEST("reorder: seq 0,2,1,3 воспроизводятся в порядке 0,1,2,3"); { - const int npulls = 12; - int freqs[4] = {300, 1000, 2000, 3000}; - uint8_t pkt[4][256]; - int lens[4]; + TEST("reorder: seq 0,2,1,3,5,4 воспроизводятся в порядке 0,1,2,3,4,5"); { + const int npulls = 16; + int freqs[6] = {300, 1000, 2000, 3000, 4000, 5000}; + uint8_t pkt[6][256]; + int lens[6]; int ok = 0; - if (encode_frames(freqs, 4, pkt, lens)) { - int seqs_in[4] = {0, 1, 2, 3}, idx_in[4] = {0, 1, 2, 3}; - int seqs_out[4] = {0, 2, 1, 3}, idx_out[4] = {0, 2, 1, 3}; + if (encode_frames(freqs, 6, pkt, lens)) { + int seqs_in[6] = {0, 1, 2, 3, 4, 5}, idx_in[6] = {0, 1, 2, 3, 4, 5}; + int seqs_out[6] = {0, 2, 1, 3, 5, 4}, idx_out[6] = {0, 2, 1, 3, 5, 4}; int16_t buf_in[npulls * 960], buf_out[npulls * 960]; - run_burst(pkt, lens, seqs_in, idx_in, 4, buf_in, npulls); - run_burst(pkt, lens, seqs_out, idx_out, 4, buf_out, npulls); + run_burst(pkt, lens, seqs_in, idx_in, 6, buf_in, npulls); + run_burst(pkt, lens, seqs_out, idx_out, 6, buf_out, npulls); ok = (memcmp(buf_in, buf_out, sizeof(buf_in)) == 0); } if (ok) OK(); else FAIL("out-of-order должен дать тот же PCM, что и in-order (reorder не сработал)"); } TEST("late seq: кадр с seq < next_seq дропается"); { - const int npulls = 12; - int freqs[4] = {300, 1000, 2000, 3000}; - uint8_t pkt[4][256]; - int lens[4]; + const int npulls = 16; + int freqs[6] = {300, 1000, 2000, 3000, 4000, 5000}; + uint8_t pkt[6][256]; + int lens[6]; int ok = 0; - if (encode_frames(freqs, 4, pkt, lens)) { - int seqs_ref[4] = {0, 1, 2, 3}, idx_ref[4] = {0, 1, 2, 3}; - int seqs_late[5] = {0, 1, 2, 3, 0}, idx_late[5] = {0, 1, 2, 3, 0}; /* последний — поздний дубль seq=0 */ + if (encode_frames(freqs, 6, pkt, lens)) { + int seqs_ref[6] = {0, 1, 2, 3, 4, 5}, idx_ref[6] = {0, 1, 2, 3, 4, 5}; + int seqs_late[7] = {0, 1, 2, 3, 4, 5, 0}, idx_late[7] = {0, 1, 2, 3, 4, 5, 0}; /* последний — поздний дубль seq=0 */ int16_t buf_ref[npulls * 960], buf_late[npulls * 960]; - run_burst(pkt, lens, seqs_ref, idx_ref, 4, buf_ref, npulls); - run_burst(pkt, lens, seqs_late, idx_late, 5, buf_late, npulls); + run_burst(pkt, lens, seqs_ref, idx_ref, 6, buf_ref, npulls); + run_burst(pkt, lens, seqs_late, idx_late, 7, buf_late, npulls); ok = (memcmp(buf_ref, buf_late, sizeof(buf_ref)) == 0); } if (ok) OK(); else FAIL("поздний дубль seq=0 должен быть дропнут (PCM совпадает с эталоном)");