Browse Source

Verify live stream compression and document shared radio AGC

master
evgeny 5 days ago
parent
commit
2a2394b34d
  1. 5
      doc/stream.txt
  2. 43
      tests/test_audio_compressor.c
  3. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/StreamService.kt

5
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,

43
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 <stdio.h>
@ -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;

16
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

Loading…
Cancel
Save