13 Commits

12 changed files with 567 additions and 106 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 = 10 versionCode = 12
versionName = "1.0.8" versionName = "1.0.10"
} }
buildTypes { buildTypes {

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

@@ -35,6 +35,8 @@ import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.proto.ReflectorClient
import ru.ua1zbe.svxremote.ui.PttButton import ru.ua1zbe.svxremote.ui.PttButton
import ru.ua1zbe.svxremote.ui.SMeterView
import ru.ua1zbe.svxremote.ui.SoundMeterPreviewView
private const val REQ_MIC = 1001 private const val REQ_MIC = 1001
private const val REQ_NOTIF = 1002 private const val REQ_NOTIF = 1002
@@ -53,14 +55,13 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var lblTg: TextView private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView private lateinit var lblTalker: TextView
private lateinit var lblDuplex: TextView
private lateinit var lblPttHint: TextView private lateinit var lblPttHint: TextView
private lateinit var lblTxState: TextView private lateinit var lblTxState: TextView
private lateinit var lblConnectHint: TextView private lateinit var lblConnectHint: TextView
private lateinit var connectZone: View private lateinit var connectZone: View
private lateinit var rxBarFill: View private lateinit var rxMeter: SMeterView
private lateinit var rxDb: TextView private lateinit var micMeter: SMeterView
private lateinit var micBarFill: View
private lateinit var micDb: 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
@@ -77,9 +78,13 @@ class MainActivity : Activity(), SvxService.Listener {
private var connected = false private var connected = false
private var monitoring = false private var monitoring = false
private var connectedSettings: ConnectionSettings? = null private var connectedSettings: ConnectionSettings? = null
private var priorityActive = false
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11) private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
// Динамический мониторинг: если на группе нет голоса 15 секунд,
// возвращаемся к мониторингу всех групп (базовая группа).
private val MONITOR_IDLE_MS = 15_000L
private var monitorIdleTask: Runnable? = null
private var svc: SvxService? = null private var svc: SvxService? = null
private val svcConn = object : ServiceConnection { private val svcConn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) { override fun onServiceConnected(name: ComponentName, binder: IBinder) {
@@ -127,16 +132,16 @@ class MainActivity : Activity(), SvxService.Listener {
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
lblTalkerTitle = findViewById(R.id.lbl_talker_title) lblTalkerTitle = findViewById(R.id.lbl_talker_title)
lblTalker = findViewById(R.id.lbl_talker) lblTalker = findViewById(R.id.lbl_talker)
lblDuplex = findViewById(R.id.lbl_duplex)
lblPttHint = findViewById(R.id.lbl_ptt_hint) lblPttHint = findViewById(R.id.lbl_ptt_hint)
lblTxState = findViewById(R.id.lbl_tx_state) lblTxState = findViewById(R.id.lbl_tx_state)
lblConnectHint = findViewById(R.id.lbl_connect_hint) lblConnectHint = findViewById(R.id.lbl_connect_hint)
connectZone = findViewById(R.id.connect_zone) connectZone = findViewById(R.id.connect_zone)
lblCallsign = findViewById(R.id.lbl_callsign) lblCallsign = findViewById(R.id.lbl_callsign)
lblTg = findViewById(R.id.lbl_tg) lblTg = findViewById(R.id.lbl_tg)
rxBarFill = findViewById(R.id.rx_bar_fill) rxMeter = findViewById(R.id.rx_meter)
rxDb = findViewById(R.id.rx_db) micMeter = findViewById(R.id.mic_meter)
micBarFill = findViewById(R.id.mic_bar_fill) micMeter.setColor(0xFFF85149.toInt())
micDb = findViewById(R.id.mic_db)
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)
@@ -166,6 +171,12 @@ class MainActivity : Activity(), SvxService.Listener {
lblTalker.setOnClickListener { showTalkLogDialog() } lblTalker.setOnClickListener { showTalkLogDialog() }
// Удержание на надписи «На связи» — демо-окно индикаторов звука
lblTxState.setOnLongClickListener {
showSoundMetersPreview()
true
}
// Удержание на названии программы (> 2 с) — выбор профиля настроек; // Удержание на названии программы (> 2 с) — выбор профиля настроек;
// короткие тапы считаем: тройной тап — настройки // короткие тапы считаем: тройной тап — настройки
lblAppTitle.setOnTouchListener { _, event -> lblAppTitle.setOnTouchListener { _, event ->
@@ -189,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) }
@@ -227,6 +238,7 @@ class MainActivity : Activity(), SvxService.Listener {
} }
lblPttHint.text = getString(R.string.ptt_talking) lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking) lblTxState.text = getString(R.string.status_big_talking)
showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
svc?.startTx() svc?.startTx()
} }
@@ -237,6 +249,7 @@ class MainActivity : Activity(), SvxService.Listener {
lblTxState.text = lblTxState.text =
if (connected) getString(R.string.status_big_connected) if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected) else getString(R.string.status_big_disconnected)
hideDuplex()
} }
}) })
@@ -254,7 +267,6 @@ class MainActivity : Activity(), SvxService.Listener {
if (prev != settings && connectedSettings != null) { if (prev != settings && connectedSettings != null) {
if (settings.host.isBlank() || settings.callsign.isBlank()) { if (settings.host.isBlank() || settings.callsign.isBlank()) {
monitoring = false monitoring = false
priorityActive = false
updateMonitorButton() updateMonitorButton()
updatePttEnabled() updatePttEnabled()
svc?.disconnect() svc?.disconnect()
@@ -388,36 +400,31 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onTalkerStart(tg: Long, callsign: String) { override fun onTalkerStart(tg: Long, callsign: String) {
mainHandler.post { mainHandler.post {
Log.d("SVXRemote", "talkerStart tg=$tg cs=$callsign monitoring=$monitoring ptt=${pttButton.isPressed2}")
talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg)) talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg))
while (talkLog.size > TALK_LOG_MAX) { while (talkLog.size > TALK_LOG_MAX) {
talkLog.removeFirst() talkLog.removeFirst()
} }
val priority = monitoring && settings.groups.firstOrNull() == tg if (monitoring) {
if (priority) { // Переключаемся на группу, где началась передача, и даём ей
priorityActive = true // 15 секунд; после тишины вернёмся ко всем группам.
} // Во время своей передачи группу не меняем.
if (priority || !priorityActive) { if (!pttButton.isPressed2) {
svc?.selectGroup(tg) svc?.selectGroup(tg)
lblTalkerTitle.text = when {
priority -> getString(R.string.talker_mon_priority, callsign, tg)
monitoring -> getString(R.string.talker_mon, callsign, tg)
else -> getString(R.string.talker_now, callsign)
} }
lblTalkerTitle.setTextColor( resetMonitorIdle()
if (priority) getColor(R.color.danger) else getColor(R.color.text_secondary) }
) lblTalkerTitle.text = getString(R.string.talker_now, tg)
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary))
lblTalker.text = callsign lblTalker.text = callsign
if (!pttButton.isPressed2) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
} }
} }
} }
override fun onTalkerStop(tg: Long, callsign: String) { override fun onTalkerStop(tg: Long, callsign: String) {
// последний говоривший остаётся на экране // последний говоривший остаётся на экране
mainHandler.post {
if (monitoring && settings.groups.firstOrNull() == tg) {
priorityActive = false
}
}
} }
override fun onNodeJoined(callsign: String, tg: Long?) { override fun onNodeJoined(callsign: String, tg: Long?) {
@@ -475,21 +482,55 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onLevel(source: Int, level: Float) { override fun onLevel(source: Int, level: Float) {
mainHandler.post { mainHandler.post {
val db = if (level <= 0.001f) {
getString(R.string.meter_idle)
} else {
getString(R.string.meter_db, (level * 60).toInt().coerceIn(1, 60))
}
if (source == 0) { // SRC_MIC if (source == 0) { // SRC_MIC
micBarFill.scaleX = level.coerceIn(0f, 1f) micMeter.setLevel(level)
micDb.text = db
} else { // SRC_SPEAKER } else { // SRC_SPEAKER
rxBarFill.scaleX = level.coerceIn(0f, 1f) rxMeter.setLevel(level)
rxDb.text = db // «RX» пока идёт голос; при своей передаче не показываем
if (!pttButton.isPressed2 && level > 0.03f) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
}
// Голос на текущей группе — откладываем возврат ко всем группам
if (monitoring && connected && level > 0.03f) {
resetMonitorIdle()
}
} }
} }
} }
/** Сбросить таймер молчания: группа жива — продолжаем её слушать. */
private fun resetMonitorIdle() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) }
val task = Runnable {
// Во время своей передачи на базу не возвращаемся
if (monitoring && connected && !pttButton.isPressed2) {
svc?.selectGroup(settings.talkGroup)
}
}
monitorIdleTask = task
mainHandler.postDelayed(task, MONITOR_IDLE_MS)
}
// ---------- Индикатор RX/TX ----------
private val DUPLEX_RX_HIDE_MS = 1500L
private var duplexHideTask: Runnable? = null
private fun showDuplex(text: String, colorRes: Int, hideAfterMs: Long?) {
lblDuplex.text = text
lblDuplex.setTextColor(getColor(colorRes))
lblDuplex.visibility = View.VISIBLE
duplexHideTask?.let { mainHandler.removeCallbacks(it) }
if (hideAfterMs != null) {
val t = Runnable { lblDuplex.visibility = View.INVISIBLE }
duplexHideTask = t
mainHandler.postDelayed(t, hideAfterMs)
}
}
private fun hideDuplex() {
duplexHideTask?.let { mainHandler.removeCallbacks(it) }
lblDuplex.visibility = View.INVISIBLE
}
override fun onError(detail: String) { override fun onError(detail: String) {
mainHandler.post { mainHandler.post {
toast(detail) toast(detail)
@@ -497,7 +538,8 @@ class MainActivity : Activity(), SvxService.Listener {
} }
private fun updatePttEnabled() { private fun updatePttEnabled() {
pttButton.enabled2 = connected && !monitoring // PTT работает и в режиме мониторинга: передача идёт на выбранной группе
pttButton.enabled2 = connected
} }
private fun requestPermissionsIfNeeded() { private fun requestPermissionsIfNeeded() {
@@ -530,7 +572,7 @@ class MainActivity : Activity(), SvxService.Listener {
}, 300) }, 300)
} }
/** Включить/выключить режим мониторинга групп с переподключением. */ /** Включить/выключить режим мониторинга групп по живому соединению. */
private fun toggleMonitoring() { private fun toggleMonitoring() {
val enabling = !monitoring val enabling = !monitoring
if (enabling) { if (enabling) {
@@ -539,12 +581,21 @@ class MainActivity : Activity(), SvxService.Listener {
showToast(R.string.monitor_off) showToast(R.string.monitor_off)
} }
monitoring = enabling monitoring = enabling
priorityActive = false
updateMonitorButton() updateMonitorButton()
updatePttEnabled() updatePttEnabled()
if (connected) { if (connected) {
svc?.disconnect() monitorIdleTask?.let { mainHandler.removeCallbacks(it) }
mainHandler.postDelayed({ connect() }, 500) if (enabling) {
// Без переподключения: сервер начнёт присылать уведомления
// со всех групп, сами сидим на базовой.
svc?.setMonitorTgs(settings.groups)
svc?.selectGroup(settings.talkGroup)
statusChip.text = getString(R.string.status_monitoring, monitorTgLabel())
} else {
svc?.selectGroup(settings.talkGroup)
svc?.setMonitorTgs(listOf(settings.talkGroup))
statusChip.text = getString(R.string.status_connected)
}
} else { } else {
connect() connect()
} }
@@ -580,7 +631,6 @@ class MainActivity : Activity(), SvxService.Listener {
Prefs.setActiveProfile(this, which) Prefs.setActiveProfile(this, which)
settings = Prefs.load(this) settings = Prefs.load(this)
monitoring = false monitoring = false
priorityActive = false
updateMonitorButton() updateMonitorButton()
updatePttEnabled() updatePttEnabled()
refreshUi() refreshUi()
@@ -617,6 +667,8 @@ class MainActivity : Activity(), SvxService.Listener {
svc?.selectGroup(tg) svc?.selectGroup(tg)
if (!monitoring) { if (!monitoring) {
svc?.setMonitorTgs(listOf(tg)) svc?.setMonitorTgs(listOf(tg))
} else {
resetMonitorIdle()
} }
} }
@@ -659,6 +711,16 @@ class MainActivity : Activity(), SvxService.Listener {
.show() .show()
} }
/** Демо-окно с примерами индикаторов звука (удержание на «На связи»). */
private fun showSoundMetersPreview() {
AlertDialog.Builder(this)
.setTitle(R.string.meter_preview_title)
.setMessage(R.string.meter_preview_hint)
.setView(SoundMeterPreviewView(this))
.setPositiveButton(R.string.close, null)
.show()
}
private fun showGroupsDialog() { private fun showGroupsDialog() {
val groups = (settings.groups + settings.talkGroup).distinct() val groups = (settings.groups + settings.talkGroup).distinct()
if (groups.isEmpty()) { if (groups.isEmpty()) {

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()
@@ -212,7 +241,9 @@ class OpusEngine(
// Уровень показываем только для реальных пакетов: во время // Уровень показываем только для реальных пакетов: во время
// скрытия потерь (conceal) шкала должна быть в нуле // скрытия потерь (conceal) шкала должна быть в нуле
val real = System.currentTimeMillis() - lastRealPacketTime < 400 val real = System.currentTimeMillis() - lastRealPacketTime < 400
onLevel(SRC_SPEAKER, if (real) pcmLevel(pcm) else 0f) val lvl = if (real) pcmLevel(pcm) else 0f
// Порог тишины: шум декодера и паузы речи — в ноль
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
if (dbgOut < 5) { if (dbgOut < 5) {
Log.d(TAG, "out ${pcm.size}B") Log.d(TAG, "out ${pcm.size}B")
dbgOut++ dbgOut++
@@ -281,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
@@ -291,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() {
@@ -439,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

@@ -410,7 +410,10 @@ class ReflectorClient(
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup)) sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup))) sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
} else { } else {
sendTcp(socket, MsgBuilder.selectTg(monitor.first())) // Мониторинг: сидим на базовой группе и слушаем
// уведомления обо всех группах (аудио идёт только
// с выбранной, поэтому переключение — в MainActivity).
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(monitor)) sendTcp(socket, MsgBuilder.tgMonitor(monitor))
} }
openUdp(s) openUdp(s)

View File

@@ -0,0 +1,128 @@
package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import kotlin.math.roundToInt
/**
* S-метр: полоса со шкалой S1–S9, +20…+60, заливкой уровня, белой пик-меткой
* (peak-hold) и цифрой текущего уровня справа («S9», «+43» или «---»).
* Уровень 0..1; S9 = 55% пути, +60 = 88%.
*/
class SMeterView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var fillColor = 0xFF58A6FF.toInt()
private val track = Paint().apply { color = 0xFF21262D.toInt() }
private val fill = Paint().apply { color = fillColor }
private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1f) }
private val tickMajor = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1.5f) }
private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2f) }
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(9)
}
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFE6EDF3.toInt()
textSize = sp(12)
textAlign = Paint.Align.RIGHT
}
private val idlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(12)
textAlign = Paint.Align.RIGHT
}
@Volatile
private var target = 0f
private var shown = 0f
private var peak = 0f
private var lastActive = 0L
private val handler = Handler(Looper.getMainLooper())
private val ticker = object : Runnable {
override fun run() {
// Гарантированный сброс: если сигнала не было 600 мс — в ноль
if (System.currentTimeMillis() - lastActive > 600 && target != 0f) target = 0f
// Сглаживание уровня и медленный спад пика
shown += (target - shown) * 0.35f
if (Math.abs(target - shown) < 0.002f) shown = target
peak = maxOf(peak * 0.985f, shown)
if (peak < 0.003f) peak = 0f
invalidate()
handler.postDelayed(this, 100)
}
}
fun setColor(color: Int) {
fillColor = color
fill.color = color
}
fun setLevel(level: Float) {
if (level > 0.01f) lastActive = System.currentTimeMillis()
target = level.coerceIn(0f, 1f)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
handler.post(ticker)
}
override fun onDetachedFromWindow() {
handler.removeCallbacksAndMessages(null)
super.onDetachedFromWindow()
}
private fun dp(v: Float): Float = v * resources.displayMetrics.density
private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity
private fun levelText(level: Float): String = when {
level < 0.005f -> "---"
level < 0.55f -> "S${(level / 0.55f * 9f).roundToInt().coerceIn(1, 9)}"
else -> "+${(((level - 0.55f) / 0.45f) * 60f).roundToInt().coerceIn(1, 99)}"
}
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
val left = dp(2f)
val right = w - dp(2f)
val bandTop = dp(4f)
val bandH = dp(14f)
// полоса
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val lvl = shown.coerceIn(0f, 1f)
if (lvl > 0.002f) {
canvas.drawRect(left, bandTop, left + lvl * (right - left), bandTop + bandH, fill)
}
// деления: S1..S9, +20..+60
val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f,
"S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f)
for ((text, pos) in marks) {
val mx = left + pos * (right - left)
val major = pos == 0.55f || pos == 0.88f
canvas.drawLine(mx, bandTop, mx, bandTop + bandH, if (major) tickMajor else tick)
canvas.drawText(text, mx - dp(8f), bandTop + bandH + dp(11f), label)
}
// пик-метка
if (peak > 0.003f) {
val px = left + peak.coerceIn(0f, 1f) * (right - left)
canvas.drawLine(px, bandTop - dp(3f), px, bandTop + bandH + dp(3f), peakPaint)
}
// цифра уровня справа от полосы (и чуть выше делений)
val p = if (target < 0.005f) idlePaint else valuePaint
canvas.drawText(levelText(target), right, bandTop + bandH - dp(3f), p)
}
}

View File

@@ -0,0 +1,156 @@
package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import kotlin.random.Random
/**
* Временное демо-окно с примерами индикаторов звука (S-метр, клиппинг,
* джиттер/потери). Открывается удержанием на надписи «На связи».
* Уровни симулируются, чтобы было видно, как индикаторы работают.
*/
class SoundMeterPreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val bg = Paint().apply { color = 0xFF0D1117.toInt() }
private val track = Paint().apply { color = 0xFF21262D.toInt() }
private val fill = Paint().apply { color = 0xFF58A6FF.toInt() }
private val fillHot = Paint().apply { color = 0xFFF85149.toInt() }
private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2) }
private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1) }
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(11)
}
private val labelSmall = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(9)
}
private val clipDot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFF85149.toInt()
}
private val clipDotOff = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF30363D.toInt()
}
private val handler = Handler(Looper.getMainLooper())
private var simLevel = 0.0f
private var simPeak = 0.0f
private var simJitter = 30.0f
private var simLoss = 0.1f
private var t = 0.0
private val anim = object : Runnable {
override fun run() {
t += 0.08
val wave = ((Math.sin(t) + Math.sin(t * 2.3)) / 2 + 1) / 2
simLevel = (wave * 0.62 + Random.nextFloat() * 0.12).toFloat()
// Периодически «перегруз» — клиппинг и пики
if (Random.nextFloat() > 0.92f) simLevel = 0.98f
simPeak = maxOf(simLevel, simPeak - 0.02f)
simJitter = 25 + Random.nextFloat() * 120
simLoss = (Random.nextFloat() * 0.5f).coerceAtLeast(0.03f)
invalidate()
handler.postDelayed(this, 50)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
handler.post(anim)
}
override fun onDetachedFromWindow() {
handler.removeCallbacksAndMessages(null)
super.onDetachedFromWindow()
}
private fun dp(v: Int): Float = v * resources.displayMetrics.density
private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
canvas.drawRect(0f, 0f, w, h, bg)
// --- S-метр ---
var y = dp(18)
canvas.drawText("S-метр (peak-hold)", dp(16), y, label)
y += dp(8)
drawSMeter(canvas, w, y)
// --- Уровень + клиппинг ---
y += dp(58)
canvas.drawText("Уровень + клиппинг", dp(16), y, label)
y += dp(8)
drawVUMeter(canvas, w, y)
// --- Джиттер / потери ---
y += dp(58)
canvas.drawText("Джиттер и потери пакетов", dp(16), y, label)
y += dp(18)
canvas.drawText(
"джиттер %d мс · потери %.1f%%".format(simJitter.toInt(), simLoss),
dp(16), y, label
)
val bar = RectF(dp(16), y + dp(6), w - dp(16), y + dp(16))
canvas.drawRect(bar, track)
val jw = (simJitter / 250f).coerceIn(0f, 1f) * bar.width()
canvas.drawRect(bar.left, bar.top, bar.left + jw, bar.bottom, fill)
}
/** S-метр: полоса со шкалой S1..S9, +20..+60 и пик-меткой. */
private fun drawSMeter(canvas: Canvas, w: Float, top: Float) {
val left = dp(16)
val right = w - dp(16)
val bandTop = top + dp(2)
val bandH = dp(14)
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val level = (simLevel).coerceIn(0f, 1f)
val fw = level * (right - left)
canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, if (level > 0.85f) fillHot else fill)
// пик-метка
val px = left + simPeak.coerceIn(0f, 1f) * (right - left)
canvas.drawLine(px, bandTop - dp(3), px, bandTop + bandH + dp(3), peakPaint)
// деления и шкала
val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f,
"S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f)
for ((text, pos) in marks) {
val mx = left + pos * (right - left)
canvas.drawLine(mx, bandTop, mx, bandTop + bandH, tick)
canvas.drawText(text, mx - dp(10), bandTop + bandH + dp(12), labelSmall)
}
canvas.drawText("+%.0f".format(level * 60), left, bandTop - dp(4), labelSmall)
}
/** VU-полоса с зоной перегрузки и красной точкой клиппинга. */
private fun drawVUMeter(canvas: Canvas, w: Float, top: Float) {
val left = dp(16)
val right = w - dp(52)
val bandTop = top + dp(2)
val bandH = dp(14)
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val level = simLevel.coerceIn(0f, 1f)
val fw = level * (right - left)
canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, fill)
// красная зона последних 15%
canvas.drawRect(
right - (right - left) * 0.15f, bandTop, right, bandTop + bandH,
if (level > 0.85f) fillHot else track
)
// клиппинг-точка
val cxp = w - dp(24)
val cyp = bandTop + bandH / 2
canvas.drawCircle(cxp, cyp, dp(7), if (level > 0.92f) clipDot else clipDotOff)
canvas.drawText("CLIP", cxp - dp(20), cyp + dp(10), labelSmall)
}
}

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

@@ -141,40 +141,22 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="top">
<TextView <TextView
android:layout_width="34dp" android:layout_width="34dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/meter_rx" android:text="@string/meter_rx"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="13sp" android:textSize="13sp"
android:textStyle="bold" /> android:textStyle="bold" />
<FrameLayout <ru.ua1zbe.svxremote.ui.SMeterView
android:id="@+id/rx_meter"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="16dp" android:layout_height="52dp"
android:layout_weight="1" android:layout_weight="1" />
android:background="@drawable/bg_meter">
<View
android:id="@+id/rx_bar_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_meter_fill_rx"
android:pivotX="0"
android:scaleX="0" />
</FrameLayout>
<TextView
android:id="@+id/rx_db"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:gravity="end"
android:fontFamily="monospace"
android:text="@string/meter_idle"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
@@ -182,40 +164,22 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="10dp" android:layout_marginTop="10dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="top">
<TextView <TextView
android:layout_width="34dp" android:layout_width="34dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/meter_mic" android:text="@string/meter_mic"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="13sp" android:textSize="13sp"
android:textStyle="bold" /> android:textStyle="bold" />
<FrameLayout <ru.ua1zbe.svxremote.ui.SMeterView
android:id="@+id/mic_meter"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="16dp" android:layout_height="52dp"
android:layout_weight="1" android:layout_weight="1" />
android:background="@drawable/bg_meter">
<View
android:id="@+id/mic_bar_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_meter_fill_mic"
android:pivotX="0"
android:scaleX="0" />
</FrameLayout>
<TextView
android:id="@+id/mic_db"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:gravity="end"
android:fontFamily="monospace"
android:text="@string/meter_idle"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -241,6 +205,17 @@
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView
android:id="@+id/lbl_duplex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:textSize="24sp"
android:textStyle="bold"
android:visibility="invisible" />
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"

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>
@@ -37,12 +40,13 @@
<string name="app_title">SVXHomyak</string> <string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string> <string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string> <string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Функция находится в разработке. Может работать некорректно!</string> <string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
<string name="monitor_off">Обычный режим</string> <string name="monitor_off">Обычный режим</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string> <string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
<string name="status_monitoring">Мониторинг: %1$s</string> <string name="status_monitoring">Мониторинг: %1$s</string>
<string name="talker_mon">Говорит: %1$s · TG%2$d</string> <string name="talker_now">Говорит TG%1$d</string>
<string name="talker_mon_priority">ПРИОРИТЕТ: %1$s · TG%2$d</string> <string name="duplex_rx">RX</string>
<string name="duplex_tx">TX</string>
<string name="status_big_disconnected">DISCONNECTED</string> <string name="status_big_disconnected">DISCONNECTED</string>
<string name="status_big_connected">CONNECTED</string> <string name="status_big_connected">CONNECTED</string>
<string name="status_big_talking">ПЕРЕДАЧА</string> <string name="status_big_talking">ПЕРЕДАЧА</string>
@@ -53,6 +57,8 @@
<string name="meter_mic">MIC</string> <string name="meter_mic">MIC</string>
<string name="meter_idle">---</string> <string name="meter_idle">---</string>
<string name="meter_db">+%1$d dB</string> <string name="meter_db">+%1$d dB</string>
<string name="meter_preview_title">Примеры индикаторов звука</string>
<string name="meter_preview_hint">Временное демо (уровни симулируются). Скажи, что реализовать по-настоящему.</string>
<string name="tg_badge">TG%1$d ▾</string> <string name="tg_badge">TG%1$d ▾</string>
<string name="talker_last_none">Последний: —</string> <string name="talker_last_none">Последний: —</string>
<string name="status_connecting">Подключение…</string> <string name="status_connecting">Подключение…</string>
@@ -68,7 +74,6 @@
<string name="btn_disconnect">Отключиться</string> <string name="btn_disconnect">Отключиться</string>
<string name="talker_none">—</string> <string name="talker_none">—</string>
<string name="talker_now">Последний: %1$s</string>
<string name="talker_on_tg">группа %1$d</string> <string name="talker_on_tg">группа %1$d</string>
<string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string> <string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string>