diff --git a/app/src/main/java/ru/ua1zbe/svxremote/AudioSettingsActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/AudioSettingsActivity.kt index 08488d0..2c2f662 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/AudioSettingsActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/AudioSettingsActivity.kt @@ -2,8 +2,10 @@ package ru.ua1zbe.svxremote import android.app.Activity import android.os.Bundle +import android.widget.ArrayAdapter import android.widget.Button import android.widget.SeekBar +import android.widget.Spinner import android.widget.TextView import android.widget.Toast import ru.ua1zbe.svxremote.prefs.Prefs @@ -25,6 +27,23 @@ class AudioSettingsActivity : Activity() { val lblRxBuffer = findViewById(R.id.lbl_rx_buffer_value) val seekJitter = findViewById(R.id.seek_jitter) val lblJitter = findViewById(R.id.lbl_jitter_value) + val spinBitrate = findViewById(R.id.spin_tx_bitrate) + + // Скорость передачи Opus: Авто + фиксированные 6..32 кбит/с. + // SvxLink: Opus 8 кГц, кадр 20 мс; декодер принимает любой битрейт, + // поэтому выбор — на стороне клиента. Авто = адаптация под интернет. + val bitrates = intArrayOf(0, 6, 8, 10, 12, 16, 20, 24, 32) + val bitrateLabels = bitrates.map { + if (it == 0) getString(R.string.audio_tx_bitrate_auto) + else getString(R.string.audio_tx_bitrate_fmt, it) + } + spinBitrate.adapter = ArrayAdapter( + this, android.R.layout.simple_spinner_item, bitrateLabels + ).apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + val savedBitrate = Prefs.txBitrateKbps(this) + spinBitrate.setSelection(bitrates.indexOfFirst { it == savedBitrate }.coerceAtLeast(0)) fun updateMicGainLabel() { lblMicGain.text = "${100 + seekMicGain.progress * 10}%" @@ -63,6 +82,7 @@ class AudioSettingsActivity : Activity() { Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f) Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20) Prefs.setRxJitterMs(this, seekJitter.progress * 20) + Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition]) Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show() finish() } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt index be9a847..12e8b23 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -200,7 +200,7 @@ class MainActivity : Activity(), SvxService.Listener { titleResetRunnable?.let { mainHandler.removeCallbacks(it) } val reset = Runnable { titleTaps = 0 } titleResetRunnable = reset - mainHandler.postDelayed(reset, 1200) + mainHandler.postDelayed(reset, 2000) if (titleTaps >= 3) { titleTaps = 0 titleResetRunnable?.let { mainHandler.removeCallbacks(it) } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt index 57406ee..13789fb 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt @@ -121,6 +121,7 @@ class SvxService : Service(), ReflectorClient.Callbacks { updateNotification(getString(R.string.notif_connected)) engine.rxBufferMs = Prefs.rxBufferMs(this) engine.jitterFrames = (Prefs.rxJitterMs(this) / 20).coerceAtLeast(1) + engine.txBitrateKbps = Prefs.txBitrateKbps(this) engine.startDecoder() } ReflectorClient.STATE_DISCONNECTED -> { @@ -174,7 +175,7 @@ class SvxService : Service(), ReflectorClient.Callbacks { } override fun onAudioPacket(packet: ByteArray, seq: Int) { - engine.onAudioPacket(packet) + engine.onAudioPacket(packet, seq) } override fun onAudioFlush() { diff --git a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt index f800e2e..4203a44 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt @@ -30,6 +30,10 @@ class OpusEngine( const val CHANNELS = 1 const val FRAME_SIZE = 160 // 20 мс в 8 кГц const val BIT_RATE = 20000 + // Авто-адаптация битрейта TX (0 = авто включено в настройках) + const val AUTO_BITRATE_MIN = 8 + const val AUTO_BITRATE_MAX = 24 + const val AUTO_BITRATE_STEP = 4 const val SRC_MIC = 0 const val SRC_SPEAKER = 1 @@ -64,9 +68,34 @@ class OpusEngine( var rxBufferMs = 417 // буфер AudioTrack, мс (40000 байт ≈ 417 мс при 48 кГц) @Volatile var jitterFrames = 10 // сколько кадров накопить до первого слива (кадр = 20 мс) + // Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет). + @Volatile + var txBitrateKbps = 0 + // Текущий битрейт в режиме Авто (пересчитывается по статистике приёма) + @Volatile + private var autoKbps = 16 private val configBuffers = ArrayList() private var configIdx = 0 + // ---------- Статистика приёма для авто-битрейта ---------- + private var lastSeq = -1 + private var lastPktTime = 0L + private var lossCount = 0L + private var pktCount = 0L + private var jitterAccum = 0.0 + private var jitterN = 0 + private var lastEvalTime = 0L + private var goodWindows = 0 + + /** Размер пакета при 20 мс кадре: bitrate * 0.02 / 8 байт. */ + fun bitrateBytes(kbps: Int): Int = (kbps * 20) / 8 + + /** Эффективный битрейт для следующей передачи, бит/с. */ + fun effectiveBitrate(): Int { + val kbps = if (txBitrateKbps > 0) txBitrateKbps else autoKbps + return kbps * 1000 + } + fun startDecoder() { if (decoder != null) return val format = MediaFormat() @@ -283,7 +312,8 @@ class OpusEngine( ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array() /** Подача одного Opus-пакета на декодер (с UDP-потока). */ - fun onAudioPacket(packet: ByteArray) { + fun onAudioPacket(packet: ByteArray, seq: Int) { + updateRxStats(seq) lastRealPacketTime = System.currentTimeMillis() rxQueue.add(packet) receivedAny = true @@ -293,11 +323,60 @@ class OpusEngine( decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) } } - /** Джиттер-гейт открыт: накопили кадры или вышло 150 мс с первого пакета. */ + /** + * Статистика приёма (потери по seq, джиттер по интервалам пакетов). + * Раз в 5 секунд корректирует autoKbps: плохо — ниже, стабильно — выше. + */ + private fun updateRxStats(seq: Int) { + val now = System.currentTimeMillis() + if (lastSeq >= 0) { + val gap = ((seq - lastSeq - 1) and 0xFFFF).toLong() + if (gap > 0) lossCount += gap + pktCount++ + val dt = now - lastPktTime + val j = abs(dt - 20).toDouble() + if (j < 100) { + jitterAccum += j + jitterN++ + } + } + lastSeq = seq + lastPktTime = now + if (lastEvalTime == 0L) { + lastEvalTime = now + return + } + if (now - lastEvalTime < 5000 || pktCount < 30) return + val lossPct = lossCount * 100.0 / pktCount + val jitterMs = if (jitterN > 0) jitterAccum / jitterN else 0.0 + if (lossPct > 3.0 || jitterMs > 60.0) { + if (autoKbps > AUTO_BITRATE_MIN) { + autoKbps -= AUTO_BITRATE_STEP + Log.d(TAG, "auto bitrate down: ${autoKbps}kbps (loss=${"%.1f".format(lossPct)}% jitter=${"%.0f".format(jitterMs)}ms)") + } + goodWindows = 0 + } else if (lossPct < 0.5 && jitterMs < 25.0) { + goodWindows++ + if (goodWindows >= 2 && autoKbps < AUTO_BITRATE_MAX) { + autoKbps += AUTO_BITRATE_STEP + Log.d(TAG, "auto bitrate up: ${autoKbps}kbps") + goodWindows = 0 + } + } else { + goodWindows = 0 + } + lossCount = 0 + pktCount = 0 + jitterAccum = 0.0 + jitterN = 0 + lastEvalTime = now + } + + /** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */ private fun gateOpen(): Boolean { if (playStarted) return true if (rxQueue.size >= jitterFrames) return true - return gateStart != 0L && System.currentTimeMillis() - gateStart >= 150 + return gateStart != 0L && System.currentTimeMillis() - gateStart >= 300 } fun onAudioFlush() { @@ -441,11 +520,12 @@ class OpusEngine( fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS) fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) - fmt.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE) + fmt.setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate()) fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, FRAME_SIZE * 2) val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS) codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.start() + Log.d(TAG, "encoder started, bitrate=${effectiveBitrate() / 1000}kbps") codec } catch (e: Exception) { null diff --git a/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt b/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt index ca97d05..80e6726 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt @@ -162,4 +162,12 @@ object Prefs { fun setRxJitterMs(context: Context, ms: Int) { sp(context).edit().putInt("rx_jitter_ms", ms.coerceIn(0, 400)).apply() } + + /** Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет). */ + fun txBitrateKbps(context: Context): Int = + sp(context).getInt("tx_bitrate_kbps", 0) + + fun setTxBitrateKbps(context: Context, kbps: Int) { + sp(context).edit().putInt("tx_bitrate_kbps", kbps.coerceIn(0, 32)).apply() + } } diff --git a/app/src/main/res/layout/activity_audio_settings.xml b/app/src/main/res/layout/activity_audio_settings.xml index 77c251d..90de46e 100644 --- a/app/src/main/res/layout/activity_audio_settings.xml +++ b/app/src/main/res/layout/activity_audio_settings.xml @@ -112,6 +112,27 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:max="20" + android:layout_marginBottom="20dp" /> + + + + + + +