4 Commits

10 changed files with 67 additions and 9 deletions

View File

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

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
@@ -78,11 +79,15 @@ class AudioSettingsActivity : Activity() {
seekRxBuffer.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 {
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])
Prefs.setRxStatsEnabled(this, chkRxStats.isChecked)
Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
finish()
}

View File

@@ -62,6 +62,7 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var connectZone: View
private lateinit var rxMeter: SMeterView
private lateinit var micMeter: SMeterView
private lateinit var lblRxStats: TextView
private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: ImageButton
@@ -142,6 +143,8 @@ class MainActivity : Activity(), SvxService.Listener {
rxMeter = findViewById(R.id.rx_meter)
micMeter = findViewById(R.id.mic_meter)
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)
btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor)
@@ -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() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) }

View File

@@ -31,12 +31,16 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun onAudioFlush()
fun onError(detail: String)
fun onLevel(source: Int, level: Float)
fun onRxStats(lossPct: Double, jitterMs: Double)
}
var listener: Listener? = null
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) }

View File

@@ -20,7 +20,8 @@ import kotlin.math.log10
* SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
*/
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 {
@@ -330,10 +331,15 @@ class OpusEngine(
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
// 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
@@ -349,6 +355,7 @@ class OpusEngine(
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
@@ -370,6 +377,7 @@ class OpusEngine(
jitterAccum = 0.0
jitterN = 0
lastEvalTime = now
onRxStats?.invoke(lossPct, jitterMs)
}
/** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */

View File

@@ -170,4 +170,12 @@ object Prefs {
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
val r = BinReader(data)
val type = r.u16()
r.u16() // client id
val cid = r.u16() // client id
val seq = r.u16()
val payload = data.copyOfRange(6, data.size)
when (type) {
@@ -549,7 +549,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload)
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)
} else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")

View File

@@ -135,6 +135,15 @@
android:layout_height="wrap_content"
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
android:id="@+id/btn_audio_save"
android:layout_width="match_parent"

View File

@@ -181,6 +181,17 @@
android:layout_height="52dp"
android:layout_weight="1" />
</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>
<!-- Последний говоривший -->

View File

@@ -27,6 +27,8 @@
<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_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string>