6 Commits

10 changed files with 203 additions and 12 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote" applicationId = "ru.ua1zbe.svxremote"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 11 versionCode = 13
versionName = "1.0.9" versionName = "1.0.11"
} }
buildTypes { buildTypes {

View File

@@ -2,8 +2,11 @@ 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.CheckBox
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 +28,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}%"
@@ -59,10 +79,15 @@ class AudioSettingsActivity : Activity() {
seekRxBuffer.setOnSeekBarChangeListener(listener) seekRxBuffer.setOnSeekBarChangeListener(listener)
seekJitter.setOnSeekBarChangeListener(listener) seekJitter.setOnSeekBarChangeListener(listener)
val chkRxStats = findViewById<CheckBox>(R.id.chk_rx_stats)
chkRxStats.isChecked = Prefs.rxStatsEnabled(this)
findViewById<Button>(R.id.btn_audio_save).setOnClickListener { findViewById<Button>(R.id.btn_audio_save).setOnClickListener {
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])
Prefs.setRxStatsEnabled(this, chkRxStats.isChecked)
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

@@ -62,6 +62,7 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var connectZone: View private lateinit var connectZone: View
private lateinit var rxMeter: SMeterView private lateinit var rxMeter: SMeterView
private lateinit var micMeter: SMeterView private lateinit var micMeter: SMeterView
private lateinit var lblRxStats: TextView
private lateinit var pttButton: PttButton private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: ImageButton private lateinit var btnMonitor: ImageButton
@@ -142,6 +143,8 @@ class MainActivity : Activity(), SvxService.Listener {
rxMeter = findViewById(R.id.rx_meter) rxMeter = findViewById(R.id.rx_meter)
micMeter = findViewById(R.id.mic_meter) micMeter = findViewById(R.id.mic_meter)
micMeter.setColor(0xFFF85149.toInt()) micMeter.setColor(0xFFF85149.toInt())
lblRxStats = findViewById(R.id.lbl_rx_stats)
lblRxStats.visibility = if (Prefs.rxStatsEnabled(this)) View.VISIBLE else View.GONE
pttButton = findViewById(R.id.ptt_button) pttButton = findViewById(R.id.ptt_button)
btnSettings = findViewById(R.id.btn_settings) btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor) btnMonitor = findViewById(R.id.btn_monitor)
@@ -200,7 +203,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) }
@@ -498,6 +501,14 @@ class MainActivity : Activity(), SvxService.Listener {
} }
} }
override fun onRxStats(lossPct: Double, jitterMs: Double) {
mainHandler.post {
if (!Prefs.rxStatsEnabled(this)) return@post
lblRxStats.visibility = View.VISIBLE
lblRxStats.text = getString(R.string.rx_stats_fmt, lossPct, jitterMs.toInt())
}
}
/** Сбросить таймер молчания: группа жива — продолжаем её слушать. */ /** Сбросить таймер молчания: группа жива — продолжаем её слушать. */
private fun resetMonitorIdle() { private fun resetMonitorIdle() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) } monitorIdleTask?.let { mainHandler.removeCallbacks(it) }

View File

@@ -31,12 +31,16 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun onAudioFlush() fun onAudioFlush()
fun onError(detail: String) fun onError(detail: String)
fun onLevel(source: Int, level: Float) fun onLevel(source: Int, level: Float)
fun onRxStats(lossPct: Double, jitterMs: Double)
} }
var listener: Listener? = null var listener: Listener? = null
private val engine by lazy { private val engine by lazy {
OpusEngine { source, level -> listener?.onLevel(source, level) } OpusEngine(
onLevel = { source, level -> listener?.onLevel(source, level) },
onRxStats = { lossPct, jitterMs -> listener?.onRxStats(lossPct, jitterMs) }
)
} }
val client: ReflectorClient by lazy { ReflectorClient(this) } val client: ReflectorClient by lazy { ReflectorClient(this) }
@@ -121,6 +125,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 +179,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

@@ -20,7 +20,8 @@ import kotlin.math.log10
* SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1. * SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
*/ */
class OpusEngine( class OpusEngine(
private val onLevel: (source: Int, level: Float) -> Unit private val onLevel: (source: Int, level: Float) -> Unit,
private val onRxStats: ((lossPct: Double, jitterMs: Double) -> Unit)? = null
) { ) {
companion object { companion object {
@@ -30,6 +31,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 +69,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 +313,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 +324,67 @@ 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 dt = now - lastPktTime
// seq — счётчик ГОВОРЯЩЕГО: при повторах кадров он может
// повторяться или идти назад — это не потери. Пауза > 200 мс
// между пакетами — смена реплики/говорящего, тоже не потери.
if (dt <= 200) {
val gap = (seq - lastSeq - 1).toLong()
if (gap in 1..50000) lossCount += gap
}
pktCount++
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
Log.d(TAG, "stats window: pkt=$pktCount loss=$lossCount pct=$lossPct j=$jitterMs lastSeq=$lastSeq")
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
onRxStats?.invoke(lossPct, jitterMs)
}
/** Джиттер-гейт открыт: накопили кадры или вышло 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 +528,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,20 @@ 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()
}
/** Показывать строку статистики приёма (потери/джиттер) на главном экране. */
fun rxStatsEnabled(context: Context): Boolean =
sp(context).getBoolean("rx_stats_enabled", true)
fun setRxStatsEnabled(context: Context, enabled: Boolean) {
sp(context).edit().putBoolean("rx_stats_enabled", enabled).apply()
}
} }

View File

@@ -541,7 +541,7 @@ class ReflectorClient(
if (data.size < 6) return if (data.size < 6) return
val r = BinReader(data) val r = BinReader(data)
val type = r.u16() val type = r.u16()
r.u16() // client id val cid = r.u16() // client id
val seq = r.u16() val seq = r.u16()
val payload = data.copyOfRange(6, data.size) val payload = data.copyOfRange(6, data.size)
when (type) { when (type) {
@@ -549,7 +549,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_UDP_AUDIO -> { ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload) val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) { if (audio != null && audio.isNotEmpty()) {
Log.d(TAG, "UDP RX audio seq=$seq len=${audio.size}") Log.d(TAG, "UDP RX audio seq=$seq cid=$cid len=${audio.size}")
callbacks.onAudioPacket(audio, seq) callbacks.onAudioPacket(audio, seq)
} else { } else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}") Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")

View File

@@ -112,8 +112,38 @@
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" />
<CheckBox
android:id="@+id/chk_rx_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/audio_rx_stats"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Button <Button
android:id="@+id/btn_audio_save" android:id="@+id/btn_audio_save"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -181,6 +181,17 @@
android:layout_height="52dp" android:layout_height="52dp"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
<!-- Статистика приёма (скрытые настройки, по умолчанию выключено) -->
<TextView
android:id="@+id/lbl_rx_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout> </LinearLayout>
<!-- Последний говоривший --> <!-- Последний говоривший -->

View File

@@ -24,6 +24,11 @@
<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="audio_rx_stats">Показывать статистику приёма (потери/джиттер)</string>
<string name="rx_stats_fmt">RX: потери %1$.1f%%, джиттер %2$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>