Browse Source

radio: AGC release-rate (без кликов), jitter dead-zone, TX/RX-статистика, FGS-гонка и кэш настроек

proxy
evgeny 2 weeks ago
parent
commit
a7a3666d85
  1. 28
      lib/audio_compressor.c
  2. 4
      lib/audio_compressor.h
  3. 13
      lib/u_async.c
  4. 19
      src/call/voice_jitter.cpp
  5. 34
      src/radio/radio_audio.c
  6. 28
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  7. 99
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  8. 23
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  9. 14
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  10. 48
      tools/chatgui/tests/test_radio_audio.cpp

28
lib/audio_compressor.c

@ -16,6 +16,7 @@ struct audio_compressor {
int lookback_blocks; int lookback_blocks;
int lookahead_blocks; int lookahead_blocks;
float rise_factor_per_block; float rise_factor_per_block;
float release_factor_per_block;
float max_gain; float max_gain;
float gain_smoothed; float gain_smoothed;
@ -38,6 +39,7 @@ struct audio_compressor {
size_t output_size; size_t output_size;
size_t output_cap; size_t output_cap;
uint32_t clip_count;
int dbg_counter; 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->lookback_blocks = ac->cfg.lookback_ms / ac->cfg.block_duration_ms;
ac->lookahead_blocks = ac->cfg.lookahead_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); 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); ac->max_gain = powf(10.0f, ac->cfg.max_gain_db / 20.0f);
DEBUG_INFO(DEBUG_CATEGORY_GENERAL, 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.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.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) { 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->accum_count = 0;
ac->output_size = 0; ac->output_size = 0;
ac->gain_smoothed = 1.0f; ac->gain_smoothed = 1.0f;
ac->clip_count = 0;
ac->dbg_counter = 0; ac->dbg_counter = 0;
} }
@ -227,25 +233,29 @@ static void process_pending_block(struct audio_compressor* ac) {
float G_prev = ac->gain_smoothed; float G_prev = ac->gain_smoothed;
if (G_raw > ac->gain_smoothed) { if (G_raw > ac->gain_smoothed) {
/* сигнал стал тише — плавно поднимаем gain (атака) */
float max_rise = ac->gain_smoothed * ac->rise_factor_per_block; float max_rise = ac->gain_smoothed * ac->rise_factor_per_block;
ac->gain_smoothed = G_raw < max_rise ? G_raw : max_rise; ac->gain_smoothed = G_raw < max_rise ? G_raw : max_rise;
} else { } 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; 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 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); 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) { 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", 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); idx, ac->block_levels[idx], envelope, 20.0f * log10f(G_raw), gain_db, (unsigned)ac->clip_count);
} }
ac->dbg_counter++; ac->dbg_counter++;
for (size_t i = 0; i < ac->pending[0].count; i++) { for (size_t i = 0; i < ac->pending[0].count; i++) {
float v = (float)ac->pending[0].samples[i] * ac->gain_smoothed; float v = (float)ac->pending[0].samples[i] * ac->gain_smoothed;
if (v > 32767.0f) v = 32767.0f; if (v > 32767.0f) { v = 32767.0f; ac->clip_count++; }
if (v < -32768.0f) v = -32768.0f; if (v < -32768.0f) { v = -32768.0f; ac->clip_count++; }
ac->pending[0].samples[i] = (int16_t)(int)v; ac->pending[0].samples[i] = (int16_t)(int)v;
} }
append_output(ac, ac->pending[0].samples, ac->pending[0].count); 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; if (!ac) return;
ac->output_size = 0; ac->output_size = 0;
} }
uint32_t audio_compressor_clip_count(const struct audio_compressor* ac) {
return ac ? ac->clip_count : 0;
}

4
lib/audio_compressor.h

@ -18,6 +18,7 @@ typedef struct {
int lookahead_ms; int lookahead_ms;
float max_gain_db; float max_gain_db;
float rise_rate_per_sec; float rise_rate_per_sec;
float release_rate_per_sec; /* скорость плавного снижения gain (0 = авто: rise*5) */
float target_level; float target_level;
} audio_compressor_config_t; } audio_compressor_config_t;
@ -40,6 +41,9 @@ size_t audio_compressor_output_size(const struct audio_compressor* ac);
* НЕ трогает gain_smoothed (адаптивная громкость сохраняется). */ * НЕ трогает gain_smoothed (адаптивная громкость сохраняется). */
void audio_compressor_clear_output(struct audio_compressor* ac); 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 #ifdef __cplusplus
} }
#endif #endif

13
lib/u_async.c

@ -1,5 +1,10 @@
// uasync.c // uasync.c
// CLOCK_BOOTTIME (Linux) требует _GNU_SOURCE в glibc.
#ifndef _GNU_SOURCE
#define _GNU_SOURCE 1
#endif
#include "u_async.h" #include "u_async.h"
#include "platform_compat.h" #include "platform_compat.h"
#include "debug_config.h" #include "debug_config.h"
@ -362,7 +367,11 @@ uint64_t get_time_tb(void) {
#else #else
uint64_t get_time_tb(void) { uint64_t get_time_tb(void) {
struct timespec ts; struct timespec ts;
#ifdef __linux__
clock_gettime(CLOCK_BOOTTIME, &ts);
#else
clock_gettime(CLOCK_MONOTONIC, &ts); clock_gettime(CLOCK_MONOTONIC, &ts);
#endif
return (uint64_t)ts.tv_sec * 10000ULL + (uint64_t)ts.tv_nsec / 100000ULL; // Преобразуем в требуемые единицы времени return (uint64_t)ts.tv_sec * 10000ULL + (uint64_t)ts.tv_nsec / 100000ULL; // Преобразуем в требуемые единицы времени
} }
#endif #endif
@ -377,7 +386,11 @@ uint64_t get_time_us(void) {
#else #else
uint64_t get_time_us(void) { uint64_t get_time_us(void) {
struct timespec ts; struct timespec ts;
#ifdef __linux__
clock_gettime(CLOCK_BOOTTIME, &ts);
#else
clock_gettime(CLOCK_MONOTONIC, &ts); clock_gettime(CLOCK_MONOTONIC, &ts);
#endif
return (uint64_t)ts.tv_sec * 1000000ULL + (uint64_t)ts.tv_nsec / 1000ULL; return (uint64_t)ts.tv_sec * 1000000ULL + (uint64_t)ts.tv_nsec / 1000ULL;
} }
#endif #endif

19
src/call/voice_jitter.cpp

@ -66,15 +66,20 @@ struct vj {
std::atomic<int> tempo_x100{100}; std::atomic<int> tempo_x100{100};
}; };
/* Симметричный целевой темп: глубина < target → min_tempo..1.0 (накопление), /* Симметричный целевой темп с мёртвой зоной ±25% вокруг target: внутри зоны темп=1.0
* глубина > target → 1.0..max_tempo (догон). */ * (без time-stretch, меньше артефактов), за её пределами — плавно к min/max. */
static double vj_compute_tempo(double depth_ms, double target_ms, double min_tempo, double max_tempo) { static double vj_compute_tempo(double depth_ms, double target_ms, double min_tempo, double max_tempo) {
if (depth_ms <= target_ms) { double dead = target_ms * 0.25;
double x = depth_ms / target_ms; /* 0 → min, 1 → 1.0 */ 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; if (x < 0.0) x = 0.0;
return min_tempo + (1.0 - min_tempo) * x; 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; if (x > 1.0) x = 1.0;
return 1.0 + (max_tempo - 1.0) * x; 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; 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) { if (depth_ms) {
/* полная глубина = кодированные кадры в кольце + невыданный PCM внутри SoundTouch */
std::lock_guard<std::mutex> lk(j->mtx); std::lock_guard<std::mutex> 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 (tempo_x100) *tempo_x100 = j->tempo_x100.load();
if (dropped) *dropped = j->dropped; if (dropped) *dropped = j->dropped;

34
src/radio/radio_audio.c

@ -38,7 +38,7 @@
#define RADIO_AUDIO_STATS_TB 10000 /* 1с: период лога статистики буфера */ #define RADIO_AUDIO_STATS_TB 10000 /* 1с: период лога статистики буфера */
/* Параметры джиттер-буфера рации (адаптивный, симметричный темп + pre-roll). */ /* Параметры джиттер-буфера рации (адаптивный, симметричный темп + 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_MIN_TEMPO 0.75 /* темп при пустом буфере (замедление) */
#define RADIO_AUDIO_MAX_TEMPO 1.6 /* темп при глубоком буфере (догон) */ #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 int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES];
static uint64_t g_last_stats_tb = 0; 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). */ /* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */
static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { 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; 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++; } if (l > 0) { opus_len[n_frames] = l; n_frames++; }
off += RADIO_AUDIO_FRAME_SAMPLES; 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); 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); 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; ua = g_inst ? g_inst->ua : NULL;
inst = g_inst; inst = g_inst;
@ -390,6 +401,19 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) {
return -1; 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++) { for (int i = 0; i < n_frames; i++) {
struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a));
if (!a) return -1; 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) { if (do_stats) {
g_last_stats_tb = now; g_last_stats_tb = now;
for (int i = 0; i < nst; i++) 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", 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, 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); st[i].tempo / 100, st[i].tempo % 100, (unsigned)st[i].dropped, (unsigned)st[i].under, st[i].reorder);
} }
/* насыщающее суммирование (soft-clip) в int16 */ /* насыщающее суммирование (soft-clip) в int16 */

28
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 и оверлей ── */ /* ── Рация: 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) { suspend fun setRadioVolumePtt(enabled: Boolean) {
radioVolumePttCached = enabled
radioVolumePttLoaded = true
context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled } context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled }
} }
fun getRadioOverlay(): Boolean = runBlocking { @Volatile private var radioOverlayCached = false
context.dataStore.data.first()[radioOverlayKey] ?: 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) { suspend fun setRadioOverlay(enabled: Boolean) {
radioOverlayCached = enabled
radioOverlayLoaded = true
context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled } context.dataStore.edit { prefs -> prefs[radioOverlayKey] = enabled }
} }

99
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 com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.concurrent.Executors
/** /**
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). * Полудуплексный аудио-движок рации (аналог 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 captureThread: Thread? = null
private var audioTrack: AudioTrack? = null @Volatile private var audioTrack: AudioTrack? = null
private var playThread: Thread? = null private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false private var prevSpeaker: Boolean = false
@ -50,22 +51,45 @@ class RadioAudioEngine {
private val _talkingFlow = MutableStateFlow(false) private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow val talkingFlow: StateFlow<Boolean> = _talkingFlow
/* Сериализованный поток для setup/teardown аудио (не блокирует main). */
private val io = Executors.newSingleThreadExecutor { r ->
Thread(r, "radio-audio-io").apply { isDaemon = true }
}
fun isActive(): Boolean = active fun isActive(): Boolean = active
fun isTalking(): Boolean = talking fun isTalking(): Boolean = talking
/** Начать прослушивание канала (переключатель рации вкл). */ /** Начать прослушивание канала (переключатель рации вкл).
fun start(group: Long): Boolean { * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
stop() 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)) { if (!NativeLib.radioAudioStart(group)) {
active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
return false return
} }
groupId = group
talking = false
val ctx = ChatApplication.instance val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode prevMode = am.mode
@ -80,26 +104,26 @@ 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)
audioRecord = try { val rec = try {
AudioRecord( AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION, MediaRecorder.AudioSource.VOICE_COMMUNICATION,
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT,
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) (recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}") 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") LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized")
audioRecord?.release(); audioRecord = null rec.release()
NativeLib.radioAudioStop(); restoreAudioMode(); return false NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
} }
// ── AudioTrack (воспроизведение) ── // ── AudioTrack (воспроизведение) ──
val trackMin = AudioTrack.getMinBufferSize( val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
audioTrack = try { val track = try {
AudioTrack( AudioTrack(
AudioAttributes.Builder() AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
@ -110,15 +134,15 @@ class RadioAudioEngine {
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
audioRecord?.release(); audioRecord = null rec.release()
audioTrack = null NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
NativeLib.radioAudioStop(); restoreAudioMode(); return false
} }
active = true audioRecord = rec
audioTrack = track
micDead = false micDead = false
audioRecord?.startRecording() rec.startRecording()
audioTrack?.play() track.play()
captureThread = Thread { captureLoop() }.apply { captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start() priority = Thread.MAX_PRIORITY; start()
@ -128,15 +152,14 @@ class RadioAudioEngine {
} }
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf")
return true
} }
fun stop() { private fun stopBlocking() {
if (!active) return releaseLocked(true)
active = false }
talking = false
_talkingFlow.value = false
/* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */
private fun releaseLocked(resetGroup: Boolean) {
captureThread?.join(1000); captureThread = null captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null playThread?.join(1000); playThread = null
@ -148,8 +171,10 @@ class RadioAudioEngine {
NativeLib.radioAudioStop() NativeLib.radioAudioStop()
restoreAudioMode() restoreAudioMode()
groupId = 0L if (resetGroup) {
LogManager.addLog("INFO", "RadioAudio", "stopped") groupId = 0L
LogManager.addLog("INFO", "RadioAudio", "stopped")
}
} }
/** PTT зажата — начинаем передачу. */ /** PTT зажата — начинаем передачу. */
@ -205,16 +230,22 @@ class RadioAudioEngine {
private fun playLoop() { private fun playLoop() {
val buf = ShortArray(FRAME_SAMPLES) val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES) val silence = ShortArray(FRAME_SAMPLES)
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */
var nextNs = System.nanoTime()
while (active) { while (active) {
val t0 = System.nanoTime()
val track = audioTrack ?: return val track = audioTrack ?: return
val n = NativeLib.radioAudioPull(groupId, buf) val n = NativeLib.radioAudioPull(groupId, buf)
if (n > 0) track.write(buf, 0, n) if (n > 0) track.write(buf, 0, n)
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - 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 /* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф */
if (sleepMs > 0) { nextNs += frameNs
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return } 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") LogManager.addLog("DEBUG", "RadioAudio", "play loop done")

23
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) private val _isShown = MutableStateFlow(false)
val isShown: StateFlow<Boolean> = _isShown val isShown: StateFlow<Boolean> = _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 = fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
fun start(ctx: Context) { 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 && if (Build.VERSION.SDK_INT >= 34 &&
ContextCompat.checkSelfPermission(ctx, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) != ContextCompat.checkSelfPermission(ctx, Manifest.permission.FOREGROUND_SERVICE_MICROPHONE) !=
PackageManager.PERMISSION_GRANTED) { PackageManager.PERMISSION_GRANTED) {
LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted") LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted")
return return
} }
if (starting || _isShown.value) return
starting = true
val i = Intent(ctx, RadioOverlayService::class.java) val i = Intent(ctx, RadioOverlayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(i) ctx.startForegroundService(i)
@ -63,6 +73,11 @@ class RadioOverlayService : Service() {
} }
fun stop(ctx: Context) { 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)) ctx.stopService(Intent(ctx, RadioOverlayService::class.java))
} }
} }
@ -76,6 +91,7 @@ class RadioOverlayService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.onCreate thread=${Thread.currentThread().name} t=${System.currentTimeMillis()}")
createChannel() createChannel()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(), ServiceCompat.startForeground(this, NOTIF_ID, buildNotification(),
@ -87,10 +103,16 @@ class RadioOverlayService : Service() {
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
if (!canDrawOverlays(this)) { if (!canDrawOverlays(this)) {
LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping") LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping")
starting = false
stopSelf() stopSelf()
return return
} }
addOverlay() addOverlay()
starting = false
if (pendingStop) {
pendingStop = false
stopSelf()
}
} }
private fun createChannel() { private fun createChannel() {
@ -212,6 +234,7 @@ class RadioOverlayService : Service() {
endTalk() endTalk()
overlayView?.let { windowManager?.removeView(it) } overlayView?.let { windowManager?.removeView(it) }
overlayView = null overlayView = null
starting = false
_isShown.value = false _isShown.value = false
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy() super.onDestroy()

14
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -65,6 +65,8 @@ class ChatViewModel : ViewModel() {
private val _radioTalking = MutableStateFlow<Long?>(null) private val _radioTalking = MutableStateFlow<Long?>(null)
val radioTalking: StateFlow<Long?> = _radioTalking val radioTalking: StateFlow<Long?> = _radioTalking
private var radioToggleCount = 0
private var callStartedMs = 0L private var callStartedMs = 0L
private var callIncomingAtMs = 0L private var callIncomingAtMs = 0L
@ -732,6 +734,12 @@ class ChatViewModel : ViewModel() {
val ch = _currentChannel.value ?: return val ch = _currentChannel.value ?: return
val groupId = ch.id.toULongOrNull()?.toLong() ?: return val groupId = ch.id.toULongOrNull()?.toLong() ?: return
val next = !_radioActive.value 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) NativeLib.radioSetActive(groupId, next)
_radioActive.value = next _radioActive.value = next
if (next) { if (next) {
@ -766,7 +774,11 @@ class ChatViewModel : ViewModel() {
private fun syncRadioOverlay() { private fun syncRadioOverlay() {
val ctx = ChatApplication.instance 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) RadioOverlayService.start(ctx)
else else
RadioOverlayService.stop(ctx) RadioOverlayService.stop(ctx)

48
tools/chatgui/tests/test_radio_audio.cpp

@ -103,50 +103,50 @@ int main() {
TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); { TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); {
radio_audio_start(GID); radio_audio_start(GID);
push_const(SRC_A, 1, 10000, 5); push_const(SRC_A, 1, 10000, 8);
int mx = pull_max_abs(10); int mx = pull_max_abs(16);
radio_audio_stop(); radio_audio_stop();
if (mx > 1000) OK(); else FAIL("max|pcm|=%d (ожидали > 1000)", mx); if (mx > 1000) OK(); else FAIL("max|pcm|=%d (ожидали > 1000)", mx);
} }
TEST("mixing: два источника складываются (soft-clip)"); { TEST("mixing: два источника складываются (soft-clip)"); {
radio_audio_start(GID); radio_audio_start(GID);
push_const(SRC_A, 1, 20000, 5); push_const(SRC_A, 1, 20000, 8);
push_const(SRC_B, 1, 20000, 5); push_const(SRC_B, 1, 20000, 8);
int mx = pull_max_abs(10); int mx = pull_max_abs(16);
radio_audio_stop(); radio_audio_stop();
if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx); if (mx > 30000) OK(); else FAIL("max|pcm|=%d (ожидали > 30000 — сумма двух источников)", mx);
} }
TEST("reorder: seq 0,2,1,3 воспроизводятся в порядке 0,1,2,3"); { TEST("reorder: seq 0,2,1,3,5,4 воспроизводятся в порядке 0,1,2,3,4,5"); {
const int npulls = 12; const int npulls = 16;
int freqs[4] = {300, 1000, 2000, 3000}; int freqs[6] = {300, 1000, 2000, 3000, 4000, 5000};
uint8_t pkt[4][256]; uint8_t pkt[6][256];
int lens[4]; int lens[6];
int ok = 0; int ok = 0;
if (encode_frames(freqs, 4, pkt, lens)) { if (encode_frames(freqs, 6, pkt, lens)) {
int seqs_in[4] = {0, 1, 2, 3}, idx_in[4] = {0, 1, 2, 3}; int seqs_in[6] = {0, 1, 2, 3, 4, 5}, idx_in[6] = {0, 1, 2, 3, 4, 5};
int seqs_out[4] = {0, 2, 1, 3}, idx_out[4] = {0, 2, 1, 3}; 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]; 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_in, idx_in, 6, buf_in, npulls);
run_burst(pkt, lens, seqs_out, idx_out, 4, buf_out, npulls); run_burst(pkt, lens, seqs_out, idx_out, 6, buf_out, npulls);
ok = (memcmp(buf_in, buf_out, sizeof(buf_in)) == 0); ok = (memcmp(buf_in, buf_out, sizeof(buf_in)) == 0);
} }
if (ok) OK(); else FAIL("out-of-order должен дать тот же PCM, что и in-order (reorder не сработал)"); if (ok) OK(); else FAIL("out-of-order должен дать тот же PCM, что и in-order (reorder не сработал)");
} }
TEST("late seq: кадр с seq < next_seq дропается"); { TEST("late seq: кадр с seq < next_seq дропается"); {
const int npulls = 12; const int npulls = 16;
int freqs[4] = {300, 1000, 2000, 3000}; int freqs[6] = {300, 1000, 2000, 3000, 4000, 5000};
uint8_t pkt[4][256]; uint8_t pkt[6][256];
int lens[4]; int lens[6];
int ok = 0; int ok = 0;
if (encode_frames(freqs, 4, pkt, lens)) { if (encode_frames(freqs, 6, pkt, lens)) {
int seqs_ref[4] = {0, 1, 2, 3}, idx_ref[4] = {0, 1, 2, 3}; int seqs_ref[6] = {0, 1, 2, 3, 4, 5}, idx_ref[6] = {0, 1, 2, 3, 4, 5};
int seqs_late[5] = {0, 1, 2, 3, 0}, idx_late[5] = {0, 1, 2, 3, 0}; /* последний — поздний дубль seq=0 */ 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]; 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_ref, idx_ref, 6, buf_ref, npulls);
run_burst(pkt, lens, seqs_late, idx_late, 5, buf_late, npulls); run_burst(pkt, lens, seqs_late, idx_late, 7, buf_late, npulls);
ok = (memcmp(buf_ref, buf_late, sizeof(buf_ref)) == 0); ok = (memcmp(buf_ref, buf_late, sizeof(buf_ref)) == 0);
} }
if (ok) OK(); else FAIL("поздний дубль seq=0 должен быть дропнут (PCM совпадает с эталоном)"); if (ok) OK(); else FAIL("поздний дубль seq=0 должен быть дропнут (PCM совпадает с эталоном)");

Loading…
Cancel
Save