From 2a2394b34de1a282411452fa45058b900c6d33da Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 13 Oct 2026 13:46:24 +0200 Subject: [PATCH] Verify live stream compression and document shared radio AGC --- doc/stream.txt | 5 +++ tests/test_audio_compressor.c | 43 +++++++++++++++++++ .../java/com/utun/chat/data/StreamService.kt | 16 ++++++- 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/doc/stream.txt b/doc/stream.txt index b5ab4a9e..e8a67556 100644 --- a/doc/stream.txt +++ b/doc/stream.txt @@ -774,6 +774,11 @@ Android: partial output собирается в access unit. Codec config передаётся с каждым IDR. - Camera MediaCodec Surface timestamps и AudioTimestamp.TIMEBASE_MONOTONIC приводятся к общему origin System.nanoTime; время callback/получения блока не заменяет capture PTS. +- Микрофон трансляции использует AudioRecord.CAMCORDER, 48kHz mono. Перед Opus AUDIO/64kbps + применяется тот же audio_compressor, что в TX рации: общие настройки включения, max_gain и rise_rate, + lookback=200ms, lookahead=0, release=rise*5, target=0.25. История и gain принадлежат только audio worker + трансляции. Изменения настроек рации применяются на следующем PCM-кадре, без перезапуска микрофона, + Opus или source_epoch; compressor не добавляет задержку и не меняет capture PTS. - Camera/codec callbacks — HandlerThread, Opus encoder — audio capture thread, decoder/AudioTrack — viewer worker. - Native bridge держит pthread mutex, ограниченные очереди и один pending post либо timer. attach/detach выполняются в uasync вне callbacks. detach отменяет post/timer, закрывает handles, diff --git a/tests/test_audio_compressor.c b/tests/test_audio_compressor.c index 722d9d5a..15776d28 100644 --- a/tests/test_audio_compressor.c +++ b/tests/test_audio_compressor.c @@ -1,5 +1,6 @@ #define _DEFAULT_SOURCE #include "audio_compressor.h" +#include "opus_codec.h" #include "debug_config.h" #include "mem.h" #include @@ -243,6 +244,47 @@ static void test_streaming_matches_recording(void) { audio_compressor_destroy(recording); audio_compressor_destroy(streaming); } +/* Тракт трансляции: усиление текущего mono-кадра перед Opus, live bypass и смена предела gain. */ +static void test_live_opus_gain(void) { + struct audio_compressor* ac = audio_compressor_create(); + opus_codec_encoder_t* encoder = opus_codec_encoder_create(48000, 1); + opus_codec_decoder_t* decoder = opus_codec_decoder_create(48000, 1); + TEST(ac && encoder && decoder, "live audio codecs created"); + if (!ac || !encoder || !decoder) goto done; + audio_compressor_config_t cfg = {.sample_rate = 48000, .channels = 1, .block_duration_ms = 20, + .lookback_ms = 200, .lookahead_ms = 0, .max_gain_db = 6, .rise_rate_per_sec = 6, + .release_rate_per_sec = 30, .target_level = 0.25f}; + TEST(audio_compressor_configure(ac, &cfg) == 0, "live radio compressor configuration"); + TEST(opus_codec_encoder_application_set(encoder, OPUS_CODEC_APP_AUDIO) == 0, "live Opus audio mode"); + TEST(opus_codec_encoder_bitrate_set(encoder, 64000) == 0, "live Opus bitrate"); + int16_t input[960], compressed[960], decoded[960]; uint8_t packet[1275]; + size_t allocations = u_get_allocated_count(); + for (int frame = 0; frame < 30; frame++) { + for (unsigned i = 0; i < 960; i++) input[i] = (int16_t)(500 * sin((frame * 960 + i) * 2 * M_PI * 440 / 48000)); + TEST(audio_compressor_process_frame(ac, input, compressed, 960) == 0, "live current frame compressed"); + int length = opus_codec_encode(encoder, compressed, 960, packet, sizeof(packet)); + TEST(length > 0, "live compressed frame encoded"); + if (length <= 0) goto done; + TEST(opus_codec_decode(decoder, packet, length, decoded, 960) == 960, "live decoded packet has 20ms of PCM"); + } + double input_energy = 0, output_energy = 0; + for (unsigned i = 0; i < 960; i++) { input_energy += (double)input[i] * input[i]; output_energy += (double)decoded[i] * decoded[i]; } + TEST(output_energy > input_energy * 2.5 && output_energy < input_energy * 5, "gain survives real Opus roundtrip"); + TEST(u_get_allocated_count() == allocations, "live audio does not accumulate compressor buffers"); + audio_compressor_set_enabled(ac, 0); + memcpy(compressed, input, sizeof(input)); + TEST(audio_compressor_process_frame(ac, compressed, compressed, 960) == 0, "live bypass in place"); + TEST(memcmp(input, compressed, sizeof(input)) == 0, "disabled compressor preserves current PCM exactly"); + cfg.max_gain_db = 20; + TEST(audio_compressor_configure(ac, &cfg) == 0, "live gain limit changed"); audio_compressor_set_enabled(ac, 1); + for (unsigned frame = 0; frame < 50; frame++) TEST(audio_compressor_process_frame(ac, input, compressed, 960) == 0, "live new gain frame"); + TEST(audio_compressor_gain_db(ac) > 19.9f && audio_compressor_gain_db(ac) <= 20.01f, "live new gain limit applied"); + TEST(audio_compressor_output_size(ac) == 0, "live gain update introduces no output backlog"); +done: + opus_codec_encoder_destroy(encoder); opus_codec_decoder_destroy(decoder); audio_compressor_destroy(ac); + TEST(u_get_allocated_count() == 0, "live Opus and compressor no leaks"); +} + int main(void) { debug_set_level(DEBUG_LEVEL_NONE); @@ -254,6 +296,7 @@ int main(void) { test_stress(); test_zero_lookahead(); test_streaming_matches_recording(); + test_live_opus_gain(); fprintf(stderr, "\n%d/%d tests passed\n", tests_passed, tests_run); return tests_passed == tests_run ? 0 : 1; diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt index 4e7369ba..a8c89ecd 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt @@ -14,6 +14,7 @@ import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import com.utun.chat.MainActivity +import com.utun.chat.ChatApplication import com.utun.chat.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -22,6 +23,10 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.collect /** Foreground-владелец камеры, микрофона и CPU lock; не восстанавливает live session после смерти процесса. */ class StreamService : Service() { @@ -56,9 +61,18 @@ class StreamService : Service() { val group = intent!!.getLongExtra("group", 0) val publisher = StreamController.state.value ?: error("Missing publisher") val config = publisher.pendingConfig ?: error("Missing capture configuration") - capture = StreamCaptureEngine(this, id, group, config, audio, publisher.preview, + val provider = ChatApplication.instance.configProvider + val audioSettings = if (audio) runBlocking { provider.radioCompressorSettings.first() } else RadioCompressorSettings() + capture = StreamCaptureEngine(this, id, group, config, audio, publisher.preview, audioSettings, { active, restored -> StreamController.captureReady(id, active, restored) }, { detail -> StreamController.failed(id, detail) }).also { it.start() } + if (audio) scope.launch { + try { provider.radioCompressorSettings.collect { capture?.audioCompressorSettings = it } } + catch (e: CancellationException) { throw e } + catch (e: Exception) { + StreamController.failed(id, "audio compressor settings: ${e.message}"); shutdown(abort = true) + } + } poll = scope.launch { val deadline = System.nanoTime() + 10_000_000_000 var opened = false