12 Commits

Author SHA1 Message Date
ua1zbe
f23d8e35bb 1.0.12: UI — убрана HOLD TO TALK, PTT крупнее, строка говорящего между блоками
- Удалена надпись HOLD TO TALK (lbl_ptt_hint).
- PTT-кнопка увеличена 130→170 dp.
- Строка говорящего перенесена между блоками «на связи» и
  индикаторами; единый формат: «Говорит UA1ZBE TG12» при приёме,
  «UA1ZBE TG12» когда приёма нет (шрифт прежний).
2026-08-21 11:54:44 +03:00
ua1zbe
587d46578e 1.0.12: PTT-кнопка в стиле Zello (градиент, тень, глянец, ободок) 2026-08-21 11:39:46 +03:00
ua1zbe
7342dddd0b 1.0.12: читаемое имя записи (дата/время вместо hex)
rec_YYYY-MM-DD_HH-mm-SS.ogg вместо rec_<hex>.ogg.
2026-08-21 11:31:58 +03:00
ua1zbe
e32d6967aa 1.0.12: список записей по долгому тапу на кнопку записи
- Long-press (3 с) кнопки записи открывает диалог со списком
  файлов recordings/*.ogg.
- В диалоге: воспроизведение (MediaPlayer, переключатель
  play/stop) и удаление с подтверждением.
- Пустой список — тост «Нет записей».
- Иконки ic_play/ic_stop/ic_delete, разметка dialog_recording_item.
2026-08-21 11:28:51 +03:00
ua1zbe
99a867df6f 1.0.12: запись приёма в OGG с пропуском тишины
- RxRecorder: MediaCodec(Opus) + MediaMuxer(OGG), пишет декодированный
  RX PCM 48 кГц; pts не продвигается во время тишины, поэтому паузы
  в эфире не попадают в файл.
- OpusEngine.rxTap: перехват PCM из выхода декодера.
- SvxService: startRecording()/stopRecording()/isRecording().
- MainActivity: кнопка btn_record между позывным и монитором,
  индикация (иконка краснеет), тосты о старте/сохранении.
- Файлы: Android/data/ru.ua1zbe.svxremote/files/recordings/rec_*.ogg.
  Пустой файл (весь сеанс — тишина) удаляется.
2026-08-21 11:09:01 +03:00
ua1zbe
29734e6203 Секции с фоном: блок индикаторов и блок APRS/статуса 2026-08-21 08:29:41 +03:00
ua1zbe
af9bb48f7e Bump to 1.0.11 2026-08-19 09:40:48 +03:00
ua1zbe
c8daa276fc Фикс подсчёта потерь: повторы/инверсии seq говорящего и паузы не считаются потерями 2026-08-19 09:37:44 +03:00
ua1zbe
1bfac034cd RX-статистика включена по умолчанию 2026-08-19 09:08:32 +03:00
ua1zbe
7b8e897e8a RX-статистика (потери/джиттер) на главном экране, галочка в скрытых настройках 2026-08-19 08:57:39 +03:00
ua1zbe
c279982778 Bump to 1.0.10 2026-08-19 08:36:59 +03:00
ua1zbe
3b2a31e133 1.0.10: выбор скорости передачи Opus + авто-адаптация; фикс подрыва в начале приёма 2026-08-19 08:35:20 +03:00
18 changed files with 809 additions and 107 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 = 14
versionName = "1.0.9" versionName = "1.0.12"
} }
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

@@ -8,7 +8,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.PixelFormat import android.graphics.PixelFormat
@@ -53,18 +57,30 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var lblSubtitle: TextView private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView private lateinit var lblTalker: TextView
private lateinit var lblDuplex: TextView private lateinit var lblDuplex: TextView
private lateinit var lblPttHint: TextView private var lastTalkerCs = ""
private var lastTalkerTg = -1L
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 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
private lateinit var btnRecord: ImageButton
// Список записей: воспроизведение/удаление
private var recPlayer: MediaPlayer? = null
private var recPlayingPath: String? = null
private var recLongPressFired = false
private var recListAdapter: ArrayAdapter<File>? = null
private val recLongPressTask = Runnable {
recLongPressFired = true
Log.d(TAG, "recBtn long-press FIRED")
openRecordingsList()
}
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null private var titleHoldRunnable: Runnable? = null
@@ -90,6 +106,7 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onServiceConnected(name: ComponentName, binder: IBinder) { override fun onServiceConnected(name: ComponentName, binder: IBinder) {
svc = (binder as SvxService.LocalBinder).service() svc = (binder as SvxService.LocalBinder).service()
svc?.listener = this@MainActivity svc?.listener = this@MainActivity
updateRecordUi(svc?.isRecording() ?: false)
} }
override fun onServiceDisconnected(name: ComponentName) { override fun onServiceDisconnected(name: ComponentName) {
@@ -130,10 +147,8 @@ class MainActivity : Activity(), SvxService.Listener {
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblAppTitle = findViewById(R.id.lbl_app_title) lblAppTitle = findViewById(R.id.lbl_app_title)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
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) lblDuplex = findViewById(R.id.lbl_duplex)
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)
@@ -142,12 +157,33 @@ 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)
btnRecord = findViewById(R.id.btn_record)
pttButton.enabled2 = false pttButton.enabled2 = false
btnMonitor.setOnClickListener { toggleMonitoring() } btnMonitor.setOnClickListener { toggleMonitoring() }
btnRecord.setOnClickListener { toggleRecording() }
btnRecord.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
recLongPressFired = false
Log.d(TAG, "recBtn DOWN, scheduling long-press")
mainHandler.postDelayed(recLongPressTask, 3000)
false
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
Log.d(TAG, "recBtn UP/CANCEL, longFired=$recLongPressFired")
mainHandler.removeCallbacks(recLongPressTask)
// Если сработал долгий тап — гасим клик (иначе переключит запись)
recLongPressFired
}
else -> false
}
}
updateMonitorButton() updateMonitorButton()
val testText = intent?.getStringExtra("node_toast_test") val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) { if (testText != null) {
@@ -200,7 +236,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) }
@@ -236,7 +272,6 @@ class MainActivity : Activity(), SvxService.Listener {
toast(getString(R.string.perm_mic)) toast(getString(R.string.perm_mic))
return return
} }
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) showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
svc?.startTx() svc?.startTx()
@@ -245,7 +280,6 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onPttUp() { override fun onPttUp() {
Log.d("SVXRemote", "PTT up") Log.d("SVXRemote", "PTT up")
svc?.stopTx() svc?.stopTx()
lblPttHint.text = getString(R.string.hold_to_talk)
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)
@@ -301,6 +335,8 @@ class MainActivity : Activity(), SvxService.Listener {
hideNodeOverlay() hideNodeOverlay()
aprsMeteo?.stop() aprsMeteo?.stop()
aprsMeteo = null aprsMeteo = null
mainHandler.removeCallbacks(recLongPressTask)
stopRecordingPlayback()
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false SplashActivity.mainAlive = false
svc?.listener = null svc?.listener = null
@@ -336,7 +372,6 @@ class MainActivity : Activity(), SvxService.Listener {
lblTxState.text = getString(R.string.status_big_disconnected) lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger)) lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect) lblConnectHint.text = getString(R.string.hint_connect)
lblPttHint.text = getString(R.string.hold_to_talk)
} }
ReflectorClient.STATE_CONNECTING, ReflectorClient.STATE_CONNECTING,
@@ -414,9 +449,9 @@ class MainActivity : Activity(), SvxService.Listener {
} }
resetMonitorIdle() resetMonitorIdle()
} }
lblTalkerTitle.text = getString(R.string.talker_now, tg) lastTalkerCs = callsign
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary)) lastTalkerTg = tg
lblTalker.text = callsign updateTalkerLine(active = true)
if (!pttButton.isPressed2) { if (!pttButton.isPressed2) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS) showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
} }
@@ -424,7 +459,20 @@ class MainActivity : Activity(), SvxService.Listener {
} }
override fun onTalkerStop(tg: Long, callsign: String) { override fun onTalkerStop(tg: Long, callsign: String) {
// последний говоривший остаётся на экране // последний говоривший остаётся на экране, но без префикса «Говорит»
mainHandler.post { updateTalkerLine(active = false) }
}
/** Единая строка говорящего: «Говорит …» при приёме, иначе позывной+TG. */
private fun updateTalkerLine(active: Boolean) {
if (lastTalkerCs.isEmpty()) {
lblTalker.text = ""
return
}
lblTalker.text = if (active)
getString(R.string.talker_active_fmt, lastTalkerCs, lastTalkerTg)
else
getString(R.string.talker_idle_fmt, lastTalkerCs, lastTalkerTg)
} }
override fun onNodeJoined(callsign: String, tg: Long?) { override fun onNodeJoined(callsign: String, tg: Long?) {
@@ -498,6 +546,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) }
@@ -658,6 +714,127 @@ class MainActivity : Activity(), SvxService.Listener {
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor) getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
} }
private fun toggleRecording() {
val s = svc ?: return
if (s.isRecording()) {
val path = s.stopRecording()
updateRecordUi(false)
toast(getString(R.string.record_saved, path ?: ""))
} else {
val path = s.startRecording()
if (path != null) {
updateRecordUi(true)
toast(getString(R.string.record_started))
} else {
toast(getString(R.string.record_failed))
}
}
}
private fun updateRecordUi(active: Boolean) {
btnRecord.imageTintList = ColorStateList.valueOf(
if (active) Color.parseColor("#F85149") else Color.parseColor("#8B949E")
)
btnRecord.contentDescription = getString(R.string.btn_record)
}
// ---------- Список записей: воспроизведение / удаление ----------
private fun recordingsDir(): File =
File(getExternalFilesDir(null) ?: filesDir, "recordings")
private fun openRecordingsList() {
Log.d(TAG, "openRecordingsList")
val dir = recordingsDir()
Log.d(TAG, "recordings dir exists=${dir.exists()} path=${dir.absolutePath}")
val files = (dir.listFiles { f -> f.extension.equals("ogg", true) }
?: emptyArray()).toMutableList()
.apply { sortByDescending { it.name } }
Log.d(TAG, "recordings count=${files.size}")
if (files.isEmpty()) {
toast("Нет записей")
return
}
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.recordings_title)
.create()
val listView = ListView(this).apply {
val adapter = object : ArrayAdapter<File>(this@MainActivity, 0, files) {
override fun getView(pos: Int, convertView: View?, parent: ViewGroup): View {
val file = getItem(pos)!!
val row = convertView
?: layoutInflater.inflate(R.layout.dialog_recording_item, parent, false)
row.findViewById<TextView>(R.id.rec_name).text = file.name
val playBtn = row.findViewById<ImageButton>(R.id.rec_play)
val delBtn = row.findViewById<ImageButton>(R.id.rec_delete)
val playing = recPlayingPath == file.absolutePath && recPlayer?.isPlaying == true
playBtn.setImageResource(if (playing) R.drawable.ic_stop else R.drawable.ic_play)
playBtn.setOnClickListener { togglePlayRecording(file.absolutePath) }
delBtn.setOnClickListener { confirmDeleteRecording(file, this, dialog) }
return row
}
}
recListAdapter = adapter
this.adapter = adapter
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.6).toInt()
)
}
dialog.setView(listView)
dialog.setOnDismissListener { stopRecordingPlayback() }
dialog.show()
}
private fun togglePlayRecording(path: String) {
if (recPlayingPath == path && recPlayer?.isPlaying == true) {
stopRecordingPlayback()
refreshRecordingList()
return
}
stopRecordingPlayback()
try {
val mp = MediaPlayer().apply {
setDataSource(path)
setOnCompletionListener { stopRecordingPlayback(); refreshRecordingList() }
prepare()
start()
}
recPlayer = mp
recPlayingPath = path
refreshRecordingList()
} catch (e: Exception) {
toast(getString(R.string.record_play_failed))
}
}
private fun stopRecordingPlayback() {
try { recPlayer?.stop() } catch (_: Exception) {}
try { recPlayer?.release() } catch (_: Exception) {}
recPlayer = null
recPlayingPath = null
}
private fun refreshRecordingList() {
recListAdapter?.notifyDataSetChanged()
}
private fun confirmDeleteRecording(file: File, adapter: ArrayAdapter<File>, dialog: AlertDialog) {
AlertDialog.Builder(this)
.setTitle(R.string.record_delete_title)
.setMessage(file.name)
.setPositiveButton(R.string.record_delete_yes) { _, _ ->
if (recPlayingPath == file.absolutePath) stopRecordingPlayback()
file.delete()
adapter.remove(file)
adapter.notifyDataSetChanged()
toast(getString(R.string.record_deleted))
if (adapter.count == 0) dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
/** Переключиться на другую группу: отправить серверу по живому соединению. */ /** Переключиться на другую группу: отправить серверу по живому соединению. */
private fun switchGroup(tg: Long) { private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg) settings = settings.copy(talkGroup = tg)

View File

@@ -10,6 +10,7 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.audio.RxRecorder
import ru.ua1zbe.svxremote.prefs.Prefs 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
@@ -31,15 +32,22 @@ 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) }
).also { it.rxTap = { pcm, real -> recorder.feed(pcm, real) } }
} }
val client: ReflectorClient by lazy { ReflectorClient(this) } val client: ReflectorClient by lazy { ReflectorClient(this) }
/** Запись приёма в OGG (управляется из UI). */
private val recorder = RxRecorder()
private val binder = LocalBinder() private val binder = LocalBinder()
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
@@ -121,6 +129,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 +183,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() {
@@ -184,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks {
override fun onError(detail: String) { override fun onError(detail: String) {
listener?.onError(detail) listener?.onError(detail)
} }
// ---------- Запись приёма в файл ----------
fun isRecording(): Boolean = recorder.isRecording()
/** Начать запись. Возвращает путь к файлу или null при ошибке. */
fun startRecording(): String? {
if (recorder.isRecording()) return recorder.getFilePath()
val dir = getExternalFilesDir(null) ?: filesDir
return if (recorder.start(dir)) recorder.getFilePath() else null
}
/** Остановить запись. Возвращает путь к сохранённому файлу или null. */
fun stopRecording(): String? {
if (!recorder.isRecording()) return null
recorder.stop()
return recorder.getFilePath()
}
override fun onDestroy() {
recorder.stop()
super.onDestroy()
}
} }

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,37 @@ 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
// Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём?
@Volatile
var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
@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,6 +245,7 @@ class OpusEngine(
// Уровень показываем только для реальных пакетов: во время // Уровень показываем только для реальных пакетов: во время
// скрытия потерь (conceal) шкала должна быть в нуле // скрытия потерь (conceal) шкала должна быть в нуле
val real = System.currentTimeMillis() - lastRealPacketTime < 400 val real = System.currentTimeMillis() - lastRealPacketTime < 400
rxTap?.invoke(pcm, real)
val lvl = if (real) pcmLevel(pcm) else 0f val lvl = if (real) pcmLevel(pcm) else 0f
// Порог тишины: шум декодера и паузы речи — в ноль // Порог тишины: шум декодера и паузы речи — в ноль
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl) onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
@@ -283,7 +317,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 +328,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 +532,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

@@ -0,0 +1,183 @@
package ru.ua1zbe.svxremote.audio
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.util.Log
import java.io.File
import java.nio.ByteBuffer
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
/**
* Запись декодированного RX-аудио (PCM 48 кГц моно 16 бит) в OGG/Opus.
* Тишина (отсутствие приёма) не пишется: во время пауз pts не продвигается,
* поэтому речь в файле склеивается без пропусков/пустых участков.
*/
class RxRecorder {
companion object {
const val TAG = "SVXRemote"
const val SAMPLE_RATE = 48000
const val CHANNELS = 1
}
@Volatile
private var muxer: MediaMuxer? = null
@Volatile
private var encoder: MediaCodec? = null
private val recording = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<ByteArray>()
private var worker: Thread? = null
private var filePath = ""
// Накопленный pts (мкс). Растёт только при подаче реального аудио (без тишины).
private var ptsUs = 0L
private var trackIndex = -1
private var muxStarted = false
private var wroteAny = false
private val info = MediaCodec.BufferInfo()
fun isRecording(): Boolean = recording.get()
fun getFilePath(): String = filePath
/** Начать запись. dir — базовая директория (используется подкаталог recordings). */
fun start(dir: File): Boolean {
if (recording.get()) return true
val outDir = File(dir, "recordings")
if (!outDir.exists()) outDir.mkdirs()
val ts = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"))
val f = File(outDir, "rec_$ts.ogg")
filePath = f.absolutePath
val mux = try {
MediaMuxer(filePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
} catch (e: Exception) {
Log.e(TAG, "recorder muxer create failed: ${e.message}")
return false
}
val fmt = MediaFormat()
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, 32000)
fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1920)
val enc = try {
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
it.start()
}
} catch (e: Exception) {
Log.e(TAG, "recorder encoder create failed: ${e.message}")
try { mux.release() } catch (_: Exception) {}
return false
}
muxer = mux
encoder = enc
trackIndex = -1
muxStarted = false
ptsUs = 0L
queue.clear()
recording.set(true)
worker = Thread({ workerLoop() }, "rx-recorder").also { it.isDaemon = true; it.start() }
Log.d(TAG, "recorder started -> $filePath")
return true
}
/** Подача декодированного PCM. isReal == false (тишина/скрытие потерь) — не пишем. */
fun feed(pcm: ByteArray, isReal: Boolean) {
if (!recording.get() || !isReal) return
queue.offer(pcm)
}
private fun workerLoop() {
val enc = encoder ?: return
val mux = muxer ?: return
val timeout = 1000L
try {
while (recording.get() || queue.isNotEmpty()) {
val pcm = queue.poll()
if (pcm != null) {
var inIdx = enc.dequeueInputBuffer(timeout)
if (inIdx >= 0) {
val buf = enc.getInputBuffer(inIdx) ?: continue
buf.clear()
buf.put(pcm)
val pts = ptsUs
enc.queueInputBuffer(inIdx, 0, pcm.size, pts, 0)
// Продвигаем pts только для реального аудио (без тишины)
ptsUs += (pcm.size / 2) * 1_000_000L / SAMPLE_RATE
} else {
queue.offer(pcm)
Thread.sleep(2)
}
}
drainEncoder(enc, mux, false)
if (!recording.get() && queue.isEmpty()) break
}
} catch (e: Exception) {
Log.e(TAG, "recorder worker error: ${e.message}")
} finally {
// Дренаж остатков после остановки
try {
drainEncoder(enc, mux, true)
} catch (_: Exception) {
}
try { enc.stop() } catch (_: Exception) {}
try { enc.release() } catch (_: Exception) {}
if (muxStarted) {
try { mux.stop() } catch (_: Exception) {}
}
try { mux.release() } catch (_: Exception) {}
encoder = null
muxer = null
Log.d(TAG, "recorder stopped -> $filePath")
}
}
private fun drainEncoder(enc: MediaCodec, mux: MediaMuxer, drainAll: Boolean) {
val timeout = if (drainAll) 0L else 1000L
var outIdx = enc.dequeueOutputBuffer(info, timeout)
while (outIdx >= 0) {
if (info.size > 0) {
if (trackIndex < 0) {
trackIndex = mux.addTrack(enc.outputFormat)
mux.start()
muxStarted = true
Log.d(TAG, "recorder muxer track added, started")
}
val outBuf = enc.getOutputBuffer(outIdx) ?: run {
enc.releaseOutputBuffer(outIdx, false)
outIdx = enc.dequeueOutputBuffer(info, timeout)
continue
}
outBuf.position(info.offset)
outBuf.limit(info.offset + info.size)
mux.writeSampleData(trackIndex, outBuf, info)
wroteAny = true
}
enc.releaseOutputBuffer(outIdx, false)
outIdx = enc.dequeueOutputBuffer(info, timeout)
}
}
fun stop() {
if (!recording.get()) return
recording.set(false)
val w = worker
if (w != null && w.isAlive) {
try { w.join(4000) } catch (_: InterruptedException) {}
}
worker = null
// Если ни одного кадра не записалось (весь сеанс — тишина) — удаляем пустой файл.
if (!wroteAny) {
try {
val f = File(filePath)
if (f.exists()) f.delete()
} catch (_: Exception) {}
}
}
}

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

@@ -3,15 +3,18 @@ package ru.ua1zbe.svxremote.ui
import android.content.Context import android.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.graphics.Shader
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import ru.ua1zbe.svxremote.R import ru.ua1zbe.svxremote.R
/** /**
* Большая круглая кнопка PTT. Удерживание — передача. * Большая круглая кнопка PTT в стиле Zello: градиентная заливка с глянцем,
* мягкая тень (плавающая кнопка) и тонкий ободок.
* Состояния: idle (синяя), нажата (красная), отключена (серая). * Состояния: idle (синяя), нажата (красная), отключена (серая).
*/ */
class PttButton @JvmOverloads constructor( class PttButton @JvmOverloads constructor(
@@ -19,8 +22,11 @@ class PttButton @JvmOverloads constructor(
attrs: AttributeSet? = null attrs: AttributeSet? = null
) : View(context, attrs) { ) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val glossPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val clip = Path()
private var radius = 0f private var radius = 0f
private var centerX = 0f private var centerX = 0f
@@ -31,7 +37,6 @@ class PttButton @JvmOverloads constructor(
set(value) { set(value) {
field = value field = value
if (!value) setPressed(false) if (!value) setPressed(false)
refreshColors()
invalidate() invalidate()
} }
@@ -50,22 +55,28 @@ class PttButton @JvmOverloads constructor(
} }
init { init {
fillPaint.style = Paint.Style.FILL
fillPaint.setShadowLayer(dp(8).toFloat(), 0f, dp(4).toFloat(), Color.argb(80, 0, 0, 0))
ringPaint.style = Paint.Style.STROKE ringPaint.style = Paint.Style.STROKE
ringPaint.strokeWidth = dp(3).toFloat() ringPaint.strokeWidth = dp(2).toFloat()
ringPaint.color = Color.parseColor("#3D444D") glossPaint.style = Paint.Style.FILL
paint.style = Paint.Style.FILL micPaint.style = Paint.Style.FILL
refreshColors() micPaint.color = Color.WHITE
isClickable = true isClickable = true
isFocusable = true isFocusable = true
} }
private fun refreshColors() { /** (верхний цвет, нижний цвет) градиента заливки. */
paint.color = when { private fun fillColors(): Pair<Int, Int> = when {
!enabled2 -> Color.parseColor("#484F58") !enabled2 -> 0x565E68 to 0x3C424B
isPressed -> Color.parseColor("#F85149") isPressed -> 0xFF8A80 to 0xDB3A30
else -> Color.parseColor("#1F6FEB") else -> 0x4A9BFF to 0x1561D6
} }
invalidate()
private fun ringColor(): Int = when {
!enabled2 -> 0x6B727B
isPressed -> 0xFFA79F
else -> 0x6FB0FF
} }
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
@@ -74,32 +85,51 @@ class PttButton @JvmOverloads constructor(
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
centerX = w / 2f centerX = w / 2f
centerY = h / 2f centerY = h / 2f
radius = minOf(w, h) / 2f - dp(16) radius = minOf(w, h) / 2f - dp(18)
refreshColors()
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
val r = radius / (1f + if (isPressed) 0.06f else 0f) val r = radius / (1f + if (isPressed) 0.06f else 0f)
canvas.drawCircle(centerX, centerY, r, paint) val cx = centerX
canvas.drawCircle(centerX, centerY, r, ringPaint) val cy = centerY
val (top, bottom) = fillColors()
// Иконка микрофона (как icons8: капсула + чашка + ножка + основание) // Заливка с вертикальным градиентом (глянец)
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG) fillPaint.shader = LinearGradient(
micPaint.color = Color.WHITE cx, cy - r, cx, cy + r, top, bottom, Shader.TileMode.CLAMP
micPaint.style = Paint.Style.FILL )
canvas.drawCircle(cx, cy, r, fillPaint)
val x = centerX // Тонкий ободок по краю
val y = centerY ringPaint.color = ringColor()
val s = r * 0.5f canvas.drawCircle(cx, cy, r - dp(1).toFloat(), ringPaint)
// Верхний глянец (белое затухание сверху)
glossPaint.shader = LinearGradient(
cx, cy - r, cx, cy + r * 0.18f,
Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP
)
clip.reset()
clip.addCircle(cx, cy, r, Path.Direction.CW)
canvas.save()
canvas.clipPath(clip)
canvas.drawRect(cx - r, cy - r, cx + r, cy + r, glossPaint)
canvas.restore()
glossPaint.shader = null
drawMic(canvas, cx, cy, r * 0.5f)
}
/** Иконка микрофона (капсула + держатель + ножка + основание). */
private fun drawMic(canvas: Canvas, x: Float, y: Float, s: Float) {
// корпус-капсула // корпус-капсула
val halfW = s * 0.34f val halfW = s * 0.34f
canvas.drawRoundRect( canvas.drawRoundRect(
x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f, x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
halfW, halfW, micPaint halfW, halfW, micPaint
) )
// чашка-держатель (U-образная, заполненная) // чашка-держатель (U-образная)
val cup = Path() val cup = Path()
cup.fillType = Path.FillType.EVEN_ODD cup.fillType = Path.FillType.EVEN_ODD
cup.moveTo(x - s * 0.52f, y + s * 0.06f) cup.moveTo(x - s * 0.52f, y + s * 0.06f)

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/surface" />
<corners android:radius="14dp" />
<stroke android:width="1dp" android:color="@color/surface_light" />
</shape>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,7h12v12a1,1 0,0 1,-1 1H7a1,1 0,0 1,-1 -1V7zM9,9v9h1V9H9zM14,9v9h1V9h-1zM4,5h16v2H4V5z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M8,5v14l11,-7z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8B949E"
android:pathData="M12,12m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M7,7h10v10h-10z" />
</vector>

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

@@ -85,10 +85,22 @@
android:ellipsize="end" android:ellipsize="end"
android:text="@string/settings_not_configured" /> android:text="@string/settings_not_configured" />
<ImageButton
android:id="@+id/btn_record"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_record"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_record"
android:scaleType="centerInside"
android:tint="@color/text_secondary" />
<ImageButton <ImageButton
android:id="@+id/btn_monitor" android:id="@+id/btn_monitor"
android:layout_width="44dp" android:layout_width="44dp"
android:layout_height="44dp" android:layout_height="44dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_headset" android:src="@drawable/ic_headset"
android:background="?android:attr/selectableItemBackgroundBorderless" android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_monitor" android:contentDescription="@string/btn_monitor"
@@ -104,38 +116,68 @@
android:textStyle="bold" android:textStyle="bold"
android:fontFamily="monospace" /> android:fontFamily="monospace" />
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/lbl_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!-- Статус соединения --> <!-- Секция: APRS/подключение + статус -->
<TextView <LinearLayout
android:id="@+id/status_chip"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="20dp" android:layout_marginTop="12dp"
android:orientation="vertical"
android:background="@drawable/bg_section"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/lbl_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="end" />
<!-- Статус соединения -->
<TextView
android:id="@+id/status_chip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/danger"
android:textSize="18sp"
android:textStyle="bold"
android:text="@string/status_disconnected" />
</LinearLayout>
<!-- Говорящий (единая строка): «Говорит …» при приёме, иначе позывной+TG -->
<TextView
android:id="@+id/lbl_talker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center" android:gravity="center"
android:textColor="@color/danger" android:textColor="@color/accent"
android:textSize="18sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
android:text="@string/status_disconnected" /> android:singleLine="true"
android:ellipsize="end" />
<!-- S-метры --> <!-- S-метры -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="28dp" android:layout_marginTop="16dp"
android:orientation="vertical"> android:orientation="vertical"
android:background="@drawable/bg_section"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -181,30 +223,19 @@
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>
<!-- Последний говоривший -->
<TextView
android:id="@+id/lbl_talker_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:text="@string/talker_last_none" />
<TextView
android:id="@+id/lbl_talker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/accent"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<!-- Индикация направления: RX (приём) / TX (передача) --> <!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView <TextView
android:id="@+id/lbl_duplex" android:id="@+id/lbl_duplex"
@@ -224,19 +255,8 @@
<!-- Кнопка PTT --> <!-- Кнопка PTT -->
<ru.ua1zbe.svxremote.ui.PttButton <ru.ua1zbe.svxremote.ui.PttButton
android:id="@+id/ptt_button" android:id="@+id/ptt_button"
android:layout_width="130dp" android:layout_width="170dp"
android:layout_height="130dp" /> android:layout_height="170dp" />
<TextView
android:id="@+id/lbl_ptt_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:textStyle="bold"
android:text="@string/hold_to_talk" />
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/rec_name"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="middle" />
<ImageButton
android:id="@+id/rec_play"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_play"
android:tint="@color/accent"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside"
android:contentDescription="Воспроизвести" />
<ImageButton
android:id="@+id/rec_delete"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_delete"
android:tint="@color/danger"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside"
android:contentDescription="Удалить" />
</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>
@@ -36,12 +41,22 @@
<string name="status_disconnected">Отключено</string> <string name="status_disconnected">Отключено</string>
<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_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</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="record_started">Запись приёма начата</string>
<string name="record_saved">Сохранено: %1$s</string>
<string name="record_failed">Не удалось начать запись (формат OGG недоступен)</string>
<string name="recordings_title">Записи приёма</string>
<string name="record_play_failed">Не удалось воспроизвести запись</string>
<string name="record_delete_title">Удалить запись?</string>
<string name="record_delete_yes">Удалить</string>
<string name="record_deleted">Удалено</string>
<string name="status_monitoring">Мониторинг: %1$s</string> <string name="status_monitoring">Мониторинг: %1$s</string>
<string name="talker_now">Говорит TG%1$d</string> <string name="talker_active_fmt">Говорит %1$s TG%2$d</string>
<string name="talker_idle_fmt">%1$s TG%2$d</string>
<string name="duplex_rx">RX</string> <string name="duplex_rx">RX</string>
<string name="duplex_tx">TX</string> <string name="duplex_tx">TX</string>
<string name="status_big_disconnected">DISCONNECTED</string> <string name="status_big_disconnected">DISCONNECTED</string>
@@ -49,7 +64,6 @@
<string name="status_big_talking">ПЕРЕДАЧА</string> <string name="status_big_talking">ПЕРЕДАЧА</string>
<string name="hint_connect">Нажмите, чтобы подключиться</string> <string name="hint_connect">Нажмите, чтобы подключиться</string>
<string name="hint_disconnect">Нажмите, чтобы отключиться</string> <string name="hint_disconnect">Нажмите, чтобы отключиться</string>
<string name="hold_to_talk">HOLD TO TALK</string>
<string name="meter_rx">RX</string> <string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string> <string name="meter_mic">MIC</string>
<string name="meter_idle">---</string> <string name="meter_idle">---</string>