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 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;
}

4
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

13
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

19
src/call/voice_jitter.cpp

@ -66,15 +66,20 @@ struct vj {
std::atomic<int> 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<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 (dropped) *dropped = j->dropped;

34
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 */

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 и оверлей ── */
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 }
}

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 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<Boolean> = _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")

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)
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 =
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()

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)
val radioTalking: StateFlow<Long?> = _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)

48
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 совпадает с эталоном)");

Loading…
Cancel
Save