1.0.10: выбор скорости передачи Opus + авто-адаптация; фикс подрыва в начале приёма

This commit is contained in:
ua1zbe
2026-08-19 08:35:20 +03:00
parent f5f51bce0c
commit 3b2a31e133
7 changed files with 139 additions and 6 deletions

View File

@@ -2,8 +2,10 @@ package ru.ua1zbe.svxremote
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
@@ -25,6 +27,23 @@ class AudioSettingsActivity : Activity() {
val lblRxBuffer = findViewById<TextView>(R.id.lbl_rx_buffer_value) val lblRxBuffer = findViewById<TextView>(R.id.lbl_rx_buffer_value)
val seekJitter = findViewById<SeekBar>(R.id.seek_jitter) val seekJitter = findViewById<SeekBar>(R.id.seek_jitter)
val lblJitter = findViewById<TextView>(R.id.lbl_jitter_value) val lblJitter = findViewById<TextView>(R.id.lbl_jitter_value)
val spinBitrate = findViewById<Spinner>(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() { fun updateMicGainLabel() {
lblMicGain.text = "${100 + seekMicGain.progress * 10}%" lblMicGain.text = "${100 + seekMicGain.progress * 10}%"
@@ -63,6 +82,7 @@ class AudioSettingsActivity : Activity() {
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f) Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20) Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20)
Prefs.setRxJitterMs(this, seekJitter.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() Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
finish() finish()
} }

View File

@@ -200,7 +200,7 @@ class MainActivity : Activity(), SvxService.Listener {
titleResetRunnable?.let { mainHandler.removeCallbacks(it) } titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
val reset = Runnable { titleTaps = 0 } val reset = Runnable { titleTaps = 0 }
titleResetRunnable = reset titleResetRunnable = reset
mainHandler.postDelayed(reset, 1200) mainHandler.postDelayed(reset, 2000)
if (titleTaps >= 3) { if (titleTaps >= 3) {
titleTaps = 0 titleTaps = 0
titleResetRunnable?.let { mainHandler.removeCallbacks(it) } titleResetRunnable?.let { mainHandler.removeCallbacks(it) }

View File

@@ -121,6 +121,7 @@ class SvxService : Service(), ReflectorClient.Callbacks {
updateNotification(getString(R.string.notif_connected)) updateNotification(getString(R.string.notif_connected))
engine.rxBufferMs = Prefs.rxBufferMs(this) engine.rxBufferMs = Prefs.rxBufferMs(this)
engine.jitterFrames = (Prefs.rxJitterMs(this) / 20).coerceAtLeast(1) engine.jitterFrames = (Prefs.rxJitterMs(this) / 20).coerceAtLeast(1)
engine.txBitrateKbps = Prefs.txBitrateKbps(this)
engine.startDecoder() engine.startDecoder()
} }
ReflectorClient.STATE_DISCONNECTED -> { ReflectorClient.STATE_DISCONNECTED -> {
@@ -174,7 +175,7 @@ class SvxService : Service(), ReflectorClient.Callbacks {
} }
override fun onAudioPacket(packet: ByteArray, seq: Int) { override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet) engine.onAudioPacket(packet, seq)
} }
override fun onAudioFlush() { override fun onAudioFlush() {

View File

@@ -30,6 +30,10 @@ class OpusEngine(
const val CHANNELS = 1 const val CHANNELS = 1
const val FRAME_SIZE = 160 // 20 мс в 8 кГц const val FRAME_SIZE = 160 // 20 мс в 8 кГц
const val BIT_RATE = 20000 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_MIC = 0
const val SRC_SPEAKER = 1 const val SRC_SPEAKER = 1
@@ -64,9 +68,34 @@ class OpusEngine(
var rxBufferMs = 417 // буфер AudioTrack, мс (40000 байт ≈ 417 мс при 48 кГц) var rxBufferMs = 417 // буфер AudioTrack, мс (40000 байт ≈ 417 мс при 48 кГц)
@Volatile @Volatile
var jitterFrames = 10 // сколько кадров накопить до первого слива (кадр = 20 мс) var jitterFrames = 10 // сколько кадров накопить до первого слива (кадр = 20 мс)
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
@Volatile
var txBitrateKbps = 0
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
@Volatile
private var autoKbps = 16
private val configBuffers = ArrayList<ByteArray>() private val configBuffers = ArrayList<ByteArray>()
private var configIdx = 0 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() { fun startDecoder() {
if (decoder != null) return if (decoder != null) return
val format = MediaFormat() val format = MediaFormat()
@@ -283,7 +312,8 @@ class OpusEngine(
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array() ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array()
/** Подача одного Opus-пакета на декодер (с UDP-потока). */ /** Подача одного Opus-пакета на декодер (с UDP-потока). */
fun onAudioPacket(packet: ByteArray) { fun onAudioPacket(packet: ByteArray, seq: Int) {
updateRxStats(seq)
lastRealPacketTime = System.currentTimeMillis() lastRealPacketTime = System.currentTimeMillis()
rxQueue.add(packet) rxQueue.add(packet)
receivedAny = true receivedAny = true
@@ -293,11 +323,60 @@ class OpusEngine(
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) } 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 { private fun gateOpen(): Boolean {
if (playStarted) return true if (playStarted) return true
if (rxQueue.size >= jitterFrames) return true if (rxQueue.size >= jitterFrames) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 150 return gateStart != 0L && System.currentTimeMillis() - gateStart >= 300
} }
fun onAudioFlush() { fun onAudioFlush() {
@@ -441,11 +520,12 @@ class OpusEngine(
fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS) fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) 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) fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, FRAME_SIZE * 2)
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS) val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start() codec.start()
Log.d(TAG, "encoder started, bitrate=${effectiveBitrate() / 1000}kbps")
codec codec
} catch (e: Exception) { } catch (e: Exception) {
null null

View File

@@ -162,4 +162,12 @@ object Prefs {
fun setRxJitterMs(context: Context, ms: Int) { fun setRxJitterMs(context: Context, ms: Int) {
sp(context).edit().putInt("rx_jitter_ms", ms.coerceIn(0, 400)).apply() 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()
}
} }

View File

@@ -112,6 +112,27 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:max="20" android:max="20"
android:layout_marginBottom="20dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/audio_tx_bitrate"
android:textColor="@color/text_primary"
android:textSize="15sp" />
</LinearLayout>
<Spinner
android:id="@+id/spin_tx_bitrate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp" /> android:layout_marginBottom="24dp" />
<Button <Button

View File

@@ -24,6 +24,9 @@
<string name="audio_mic_gain">Усиление микрофона</string> <string name="audio_mic_gain">Усиление микрофона</string>
<string name="audio_rx_buffer">Буфер воспроизведения</string> <string name="audio_rx_buffer">Буфер воспроизведения</string>
<string name="audio_jitter">Накопление перед стартом</string> <string name="audio_jitter">Накопление перед стартом</string>
<string name="audio_tx_bitrate">Скорость передачи (Opus)</string>
<string name="audio_tx_bitrate_auto">Авто</string>
<string name="audio_tx_bitrate_fmt">%1$d кбит/с</string>
<string name="toast_node_joined">Подключился: %1$s%2$s</string> <string name="toast_node_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string> <string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string> <string name="node_tg_suffix"> (группа %1$d)</string>