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 58940b21..ba38ffd4 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 @@ -12,6 +12,7 @@ import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.runBlocking import java.net.Inet4Address import java.net.Inet6Address @@ -34,6 +35,8 @@ data class VoiceSettings( val targetLevelDb: Int = -3 ) +data class RadioCompressorSettings(val enabled: Boolean = true, val maxGainDb: Int = 15, val riseRate: Int = 6) + class ConfigProvider(private val context: Context) { private val nodePrivateKey = stringPreferencesKey("node_private_key") private val nodePublicKey = stringPreferencesKey("node_public_key") @@ -88,6 +91,12 @@ class ConfigProvider(private val context: Context) { private val radioCompressorEnabledKey = intPreferencesKey("radio_compressor_enabled") private val radioCompressorMaxGainDbKey = intPreferencesKey("radio_compressor_max_gain_db") + // Трансляция наблюдает те же настройки TX, что и рация; история PCM у каждого владельца своя. + val radioCompressorSettings: Flow = context.dataStore.data.map { prefs -> + RadioCompressorSettings((prefs[radioCompressorEnabledKey] ?: 1) != 0, + prefs[radioCompressorMaxGainDbKey] ?: 15, prefs[chatCompressorRiseRate] ?: 6) + }.distinctUntilChanged() + private fun keysFilePath(): String { val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath return "$dir/keys.conf" diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index df3c603a..cb18de52 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -209,7 +209,8 @@ object NativeLib { fun streamStop(id: Long) { nativeStreamStop(id) } fun streamPull(id: Long): ByteArray? = nativeStreamPull(id) fun streamOpusCreate(encode: Boolean): Long = nativeStreamOpusCreate(encode) - fun streamOpusEncode(handle: Long, pcm: ShortArray): ByteArray? = nativeStreamOpusEncode(handle, pcm) + fun streamOpusEncode(handle: Long, pcm: ShortArray, compressor: RadioCompressorSettings): ByteArray? = + nativeStreamOpusEncode(handle, pcm, compressor.enabled, compressor.maxGainDb, compressor.riseRate) fun streamOpusDecode(handle: Long, packet: ByteArray): ShortArray? = nativeStreamOpusDecode(handle, packet) fun streamOpusClose(handle: Long) { nativeStreamOpusClose(handle) } private external fun nativeStreamOpen(id: Long, group: Long, source: Long, width: Int, height: Int, rotation: Int, @@ -220,7 +221,7 @@ object NativeLib { private external fun nativeStreamStop(id: Long) private external fun nativeStreamPull(id: Long): ByteArray? private external fun nativeStreamOpusCreate(encode: Boolean): Long - private external fun nativeStreamOpusEncode(handle: Long, pcm: ShortArray): ByteArray? + private external fun nativeStreamOpusEncode(handle: Long, pcm: ShortArray, compressor: Boolean, maxGainDb: Int, riseRate: Int): ByteArray? private external fun nativeStreamOpusDecode(handle: Long, packet: ByteArray): ShortArray? private external fun nativeStreamOpusClose(handle: Long) diff --git a/tools/chatgui-android/jni_bridge/android_stream_jni.c b/tools/chatgui-android/jni_bridge/android_stream_jni.c index f9a342ec..f85b8726 100644 --- a/tools/chatgui-android/jni_bridge/android_stream_jni.c +++ b/tools/chatgui-android/jni_bridge/android_stream_jni.c @@ -2,14 +2,23 @@ #ifdef __ANDROID__ #include #include +#include #include "../../../lib/debug_config.h" #include "../../../lib/mem.h" #include "../../../lib/opus_codec.h" +#include "../../../lib/audio_compressor.h" #include "../libutun_lite/stream_bridge.h" #include "../../../src/stream/stream_media.h" -struct android_stream_opus { opus_codec_encoder_t *encoder; opus_codec_decoder_t *decoder; }; +struct android_stream_opus { + opus_codec_encoder_t *encoder; + opus_codec_decoder_t *decoder; + struct audio_compressor *compressor; + int compressor_enabled, max_gain_db, rise_rate; + uint64_t frames; + uint32_t reported_clips; +}; JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamOpen( JNIEnv *env, jobject obj, jlong id, jlong group, jlong source, jint width, jint height, jint rotation, jboolean audio, jlong epoch) { @@ -61,29 +70,65 @@ JNIEXPORT jlong JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamOpusCreate codec->encoder = opus_codec_encoder_create(48000, 1); if (!codec->encoder || opus_codec_encoder_application_set(codec->encoder, OPUS_CODEC_APP_AUDIO) < 0 || opus_codec_encoder_bitrate_set(codec->encoder, 64000) < 0) goto fail; + codec->compressor = audio_compressor_create(); + if (!codec->compressor) goto fail; } else { codec->decoder = opus_codec_decoder_create(48000, 1); if (!codec->decoder) goto fail; } - DEBUG_INFO(DEBUG_CATEGORY_STREAM, "JNI stream Opus created encode=%d rate=48000 channels=1 frame=960", encode); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "JNI stream Opus created codec=%p encode=%d rate=48000 channels=1 frame=960", (void *)codec, encode); return (jlong)(intptr_t)codec; fail: DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream Opus initialization failed encode=%d", encode); + audio_compressor_destroy(codec->compressor); opus_codec_encoder_destroy(codec->encoder); opus_codec_decoder_destroy(codec->decoder); u_free(codec); return 0; } JNIEXPORT jbyteArray JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamOpusEncode( - JNIEnv *env, jobject obj, jlong handle, jshortArray samples) { + JNIEnv *env, jobject obj, jlong handle, jshortArray samples, jboolean enabled, jint max_gain_db, jint rise_rate) { (void)obj; struct android_stream_opus *codec = (struct android_stream_opus *)(intptr_t)handle; - if (!codec || !codec->encoder || !samples || (*env)->GetArrayLength(env, samples) != 960) { + if (!codec || !codec->encoder || !codec->compressor || !samples || (*env)->GetArrayLength(env, samples) != 960) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream Opus encode invalid handle/frame"); return NULL; } + if (max_gain_db < 1 || max_gain_db > 60 || rise_rate < 1 || rise_rate > 100) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream compressor invalid max_gain=%d rise=%d", max_gain_db, rise_rate); return NULL; + } + if (codec->compressor_enabled != !!enabled || codec->max_gain_db != max_gain_db || codec->rise_rate != rise_rate) { + audio_compressor_config_t config = {0}; + config.sample_rate = 48000; config.channels = 1; config.block_duration_ms = 20; + config.lookback_ms = 200; config.lookahead_ms = 0; config.target_level = 0.25f; + config.max_gain_db = max_gain_db; config.rise_rate_per_sec = rise_rate; config.release_rate_per_sec = rise_rate * 5.0f; + if (audio_compressor_configure(codec->compressor, &config) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream compressor configuration failed codec=%p", (void *)codec); return NULL; + } + audio_compressor_set_enabled(codec->compressor, enabled); + codec->compressor_enabled = !!enabled; codec->max_gain_db = max_gain_db; codec->rise_rate = rise_rate; codec->reported_clips = 0; + DEBUG_INFO(DEBUG_CATEGORY_STREAM, + "JNI stream compressor applied codec=%p enabled=%d max_gain=%ddB rise=%d release=%d target=0.25 history=200ms delay=0ms", + (void *)codec, !!enabled, max_gain_db, rise_rate, rise_rate * 5); + } int16_t pcm[960]; uint8_t packet[1275]; (*env)->GetShortArrayRegion(env, samples, 0, 960, pcm); if ((*env)->ExceptionCheck(env)) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream PCM read failed"); return NULL; } + int report = codec->frames % 50 == 0, input_peak = 0, output_peak = 0; + if (report) for (unsigned i = 0; i < 960; i++) if (abs((int)pcm[i]) > input_peak) input_peak = abs((int)pcm[i]); + if (audio_compressor_process_frame(codec->compressor, pcm, pcm, 960) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream compressor processing failed codec=%p", (void *)codec); return NULL; + } int length = opus_codec_encode(codec->encoder, pcm, 960, packet, sizeof(packet)); if (length < 0) { DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream Opus encode failed result=%d", length); return NULL; } + codec->frames++; + if (report) { + for (unsigned i = 0; i < 960; i++) if (abs((int)pcm[i]) > output_peak) output_peak = abs((int)pcm[i]); + uint32_t clips = audio_compressor_clip_count(codec->compressor); + DEBUG_DEBUG(DEBUG_CATEGORY_STREAM, "JNI stream audio codec=%p frames=%llu enabled=%d gain=%.1fdB peaks=%d->%d clips=%u packet=%d", + (void *)codec, (unsigned long long)codec->frames, codec->compressor_enabled, + audio_compressor_gain_db(codec->compressor), input_peak, output_peak, clips, length); + if (clips != codec->reported_clips) DEBUG_WARN(DEBUG_CATEGORY_STREAM, "JNI stream audio clipped codec=%p samples=%u gain=%.1fdB", + (void *)codec, clips - codec->reported_clips, audio_compressor_gain_db(codec->compressor)); + codec->reported_clips = clips; + } jbyteArray output = (*env)->NewByteArray(env, length); if (output) (*env)->SetByteArrayRegion(env, output, 0, length, (const jbyte *)packet); else DEBUG_ERROR(DEBUG_CATEGORY_STREAM, "JNI stream Opus output allocation failed"); @@ -112,7 +157,9 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeStreamOpusClose(J (void)env; (void)obj; struct android_stream_opus *codec = (struct android_stream_opus *)(intptr_t)handle; if (!codec) return; - DEBUG_INFO(DEBUG_CATEGORY_STREAM, "JNI stream Opus release encode=%d", codec->encoder != NULL); + DEBUG_INFO(DEBUG_CATEGORY_STREAM, "JNI stream Opus release codec=%p encode=%d frames=%llu", (void *)codec, + codec->encoder != NULL, (unsigned long long)codec->frames); + audio_compressor_destroy(codec->compressor); opus_codec_encoder_destroy(codec->encoder); opus_codec_decoder_destroy(codec->decoder); u_free(codec); } #endif