1 Commits
v1.0 ... v1.0.0

Author SHA1 Message Date
ua1zbe
805aaa2411 Fix Opus RX decoding, add foreground service, last-talker display
- Decode received Opus audio: handle svxlink self-delimited framing and
  provide monotonic PTS (c2.opus.decoder discards output when PTS == 0)
- Keep connection and playback alive in background via SvxService
  (foreground service, media playback type)
- Keep screen on while app is open
- Show last talker callsign persistently instead of clearing after 4s
- Bump versionCode to 2, disable lint on release builds
2026-08-13 16:23:41 +03:00
10 changed files with 413 additions and 124 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 = 1 versionCode = 2
versionName = "1.0" versionName = "1.0.0"
} }
buildTypes { buildTypes {
@@ -21,6 +21,11 @@ android {
} }
} }
lint {
checkReleaseBuilds = false
abortOnError = false
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
@@ -39,8 +44,11 @@ dependencies {
android { android {
testOptions { testOptions {
unitTests.all { unitTests {
it.maxHeapSize = "256m" isReturnDefaultValues = true
all {
it.maxHeapSize = "256m"
}
} }
} }
} }

View File

@@ -4,6 +4,8 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application <application
android:label="@string/app_name" android:label="@string/app_name"
@@ -12,6 +14,11 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SvxRemote"> android:theme="@style/Theme.SvxRemote">
<service
android:name=".SvxService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" android:exported="false"

View File

@@ -2,17 +2,22 @@ package ru.ua1zbe.svxremote
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import android.view.WindowManager
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
@@ -21,8 +26,9 @@ import ru.ua1zbe.svxremote.ui.LevelMeter
import ru.ua1zbe.svxremote.ui.PttButton import ru.ua1zbe.svxremote.ui.PttButton
private const val REQ_MIC = 1001 private const val REQ_MIC = 1001
private const val REQ_NOTIF = 1002
class MainActivity : Activity(), ReflectorClient.Callbacks { class MainActivity : Activity(), SvxService.Listener {
companion object { companion object {
private const val TAG = "SVXRemote" private const val TAG = "SVXRemote"
@@ -39,22 +45,27 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
private lateinit var levelMeter: LevelMeter private lateinit var levelMeter: LevelMeter
private lateinit var btnSettings: ImageButton private lateinit var btnSettings: ImageButton
private lateinit var engine: OpusEngine
private lateinit var client: ReflectorClient
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var connected = false private var connected = false
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11) private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
private val talkerTimeout = object : Runnable { private var svc: SvxService? = null
override fun run() { private val svcConn = object : ServiceConnection {
clearTalker() override fun onServiceConnected(name: ComponentName, binder: IBinder) {
svc = (binder as SvxService.LocalBinder).service()
svc?.listener = this@MainActivity
}
override fun onServiceDisconnected(name: ComponentName) {
svc = null
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
@@ -67,18 +78,14 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
levelMeter = findViewById(R.id.level_meter) levelMeter = findViewById(R.id.level_meter)
btnSettings = findViewById(R.id.btn_settings) btnSettings = findViewById(R.id.btn_settings)
engine = OpusEngine { source, level ->
levelMeter.setLevel(source, level)
}
client = ReflectorClient(this)
btnSettings.setOnClickListener { btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
btnConnect.setOnClickListener { btnConnect.setOnClickListener {
if (connected) { val s = svc
client.disconnect() if (connected && s != null) {
s.disconnect()
} else { } else {
settings = Prefs.load(this) settings = Prefs.load(this)
if (settings.host.isBlank() || settings.callsign.isBlank()) { if (settings.host.isBlank() || settings.callsign.isBlank()) {
@@ -89,15 +96,24 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC) requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC)
return@setOnClickListener return@setOnClickListener
} }
client.connect( if (Build.VERSION.SDK_INT >= 33 &&
ReflectorClient.Settings( checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
host = settings.host, ) {
port = settings.port, requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIF)
callsign = settings.callsign, }
authKey = settings.authKey, startForegroundService(Intent(this, SvxService::class.java))
talkGroup = settings.talkGroup bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE)
mainHandler.postDelayed({
svc?.connect(
ReflectorClient.Settings(
host = settings.host,
port = settings.port,
callsign = settings.callsign,
authKey = settings.authKey,
talkGroup = settings.talkGroup
)
) )
) }, 300)
} }
} }
@@ -113,14 +129,11 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
} }
lblTxState.visibility = TextView.VISIBLE lblTxState.visibility = TextView.VISIBLE
lblPttHint.text = getString(R.string.ptt_talking) lblPttHint.text = getString(R.string.ptt_talking)
engine.startCapture { packet -> svc?.startTx()
client.sendAudio(packet)
}
} }
override fun onPttUp() { override fun onPttUp() {
engine.stopCapture() svc?.stopTx()
client.sendAudioFlush()
lblTxState.visibility = TextView.GONE lblTxState.visibility = TextView.GONE
lblPttHint.text = getString(R.string.ptt_hint) lblPttHint.text = getString(R.string.ptt_hint)
} }
@@ -135,6 +148,12 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
refreshUi() refreshUi()
} }
override fun onDestroy() {
super.onDestroy()
svc?.listener = null
unbindService(svcConn)
}
private fun refreshUi() { private fun refreshUi() {
val host = settings.host val host = settings.host
lblSubtitle.text = if (host.isBlank()) { lblSubtitle.text = if (host.isBlank()) {
@@ -144,7 +163,7 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
} }
} }
// ---------- Callbacks от ReflectorClient (фоновые потоки!) ---------- // ---------- Callbacks от SvxService (фоновые потоки!) ----------
override fun onState(state: Int, detail: String?) { override fun onState(state: Int, detail: String?) {
Log.d(TAG, "state=$state detail=$detail") Log.d(TAG, "state=$state detail=$detail")
@@ -205,29 +224,23 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
mainHandler.post { mainHandler.post {
lblTalkerTitle.text = getString(R.string.talker_now, callsign) lblTalkerTitle.text = getString(R.string.talker_now, callsign)
lblTalker.text = callsign lblTalker.text = callsign
mainHandler.removeCallbacks(talkerTimeout)
mainHandler.postDelayed(talkerTimeout, 4000)
} }
} }
override fun onTalkerStop(tg: Long, callsign: String) { override fun onTalkerStop(tg: Long, callsign: String) {
mainHandler.post { // последний говоривший остаётся на экране
clearTalker()
}
}
override fun onNodeJoined(callsign: String) {
}
override fun onNodeLeft(callsign: String) {
} }
override fun onAudioPacket(packet: ByteArray, seq: Int) { override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet)
} }
override fun onAudioFlush() { override fun onAudioFlush() {
engine.onAudioFlush() }
override fun onLevel(source: Int, level: Float) {
mainHandler.post {
levelMeter.setLevel(source, level)
}
} }
override fun onError(detail: String) { override fun onError(detail: String) {
@@ -236,12 +249,6 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
} }
} }
private fun clearTalker() {
lblTalkerTitle.text = getString(R.string.talker_none)
lblTalker.text = getString(R.string.talker_none)
mainHandler.removeCallbacks(talkerTimeout)
}
private fun updatePttEnabled() { private fun updatePttEnabled() {
pttButton.enabled2 = connected pttButton.enabled2 = connected
} }
@@ -250,13 +257,6 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
} }
override fun onDestroy() {
super.onDestroy()
mainHandler.removeCallbacks(talkerTimeout)
client.disconnect()
engine.releaseAll()
}
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,

View File

@@ -0,0 +1,127 @@
package ru.ua1zbe.svxremote
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient
/**
* Foreground-сервис: держит соединение с рефлектором и декодер
* живыми в фоне и при выключенном экране.
*/
class SvxService : Service(), ReflectorClient.Callbacks {
interface Listener {
fun onState(state: Int, detail: String?)
fun onServerInfo(info: MsgParser.ServerInfo)
fun onTalkerStart(tg: Long, callsign: String)
fun onTalkerStop(tg: Long, callsign: String)
fun onAudioPacket(packet: ByteArray, seq: Int)
fun onAudioFlush()
fun onError(detail: String)
fun onLevel(source: Int, level: Float)
}
var listener: Listener? = null
private val engine by lazy {
OpusEngine { source, level -> listener?.onLevel(source, level) }
}
val client: ReflectorClient by lazy { ReflectorClient(this) }
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun service(): SvxService = this@SvxService
}
override fun onBind(intent: Intent): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
return START_STICKY
}
private fun startForegroundCompat() {
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel("svx", "SVXRemote", NotificationManager.IMPORTANCE_LOW)
)
val n = Notification.Builder(this, "svx")
.setContentTitle("SVXRemote")
.setContentText(getString(R.string.notif_connected))
.setSmallIcon(android.R.drawable.ic_menu_call)
.setOngoing(true)
.build()
startForeground(1, n)
}
fun connect(settings: ReflectorClient.Settings) {
startForegroundCompat()
client.connect(settings)
}
fun disconnect() {
client.disconnect()
}
fun isRunning(): Boolean = client.isRunning()
fun startTx() {
engine.startCapture { packet -> client.sendAudio(packet) }
}
fun stopTx() {
engine.stopCapture()
client.sendAudioFlush()
}
// ---------- Callbacks (фоновые потоки) ----------
override fun onState(state: Int, detail: String?) {
when (state) {
ReflectorClient.STATE_READY -> engine.startDecoder()
ReflectorClient.STATE_DISCONNECTED -> {
engine.stopDecoder()
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
listener?.onState(state, detail)
}
override fun onServerInfo(info: MsgParser.ServerInfo) {
listener?.onServerInfo(info)
}
override fun onTalkerStart(tg: Long, callsign: String) {
listener?.onTalkerStart(tg, callsign)
}
override fun onTalkerStop(tg: Long, callsign: String) {
listener?.onTalkerStop(tg, callsign)
}
override fun onNodeJoined(callsign: String) {
}
override fun onNodeLeft(callsign: String) {
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet)
}
override fun onAudioFlush() {
engine.onAudioFlush()
}
override fun onError(detail: String) {
listener?.onError(detail)
}
}

View File

@@ -9,6 +9,7 @@ import android.media.MediaCodecInfo
import android.media.MediaFormat import android.media.MediaFormat
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.math.abs import kotlin.math.abs
@@ -23,7 +24,9 @@ class OpusEngine(
) { ) {
companion object { companion object {
const val TAG = "SVXRemote"
const val SAMPLE_RATE = 8000 const val SAMPLE_RATE = 8000
const val OUT_SAMPLE_RATE = 48000 // декодер opus всегда выдаёт 48 кГц
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
@@ -32,7 +35,9 @@ class OpusEngine(
const val SRC_SPEAKER = 1 const val SRC_SPEAKER = 1
} }
@Volatile
private var encoder: MediaCodec? = null private var encoder: MediaCodec? = null
@Volatile
private var decoder: MediaCodec? = null private var decoder: MediaCodec? = null
private var recorder: AudioRecord? = null private var recorder: AudioRecord? = null
private var player: AudioTrack? = null private var player: AudioTrack? = null
@@ -46,6 +51,11 @@ class OpusEngine(
// ---------- Воспроизведение (RX) ---------- // ---------- Воспроизведение (RX) ----------
private var headSent = false
private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue<ByteArray>()
private var drainThread: Thread? = null
private var ptsCounter = 0L
fun startDecoder() { fun startDecoder() {
if (decoder != null) return if (decoder != null) return
val format = MediaFormat() val format = MediaFormat()
@@ -53,75 +63,164 @@ class OpusEngine(
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192) format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
if (Build.VERSION.SDK_INT >= 29) { // Android 16: c2.android.opus.decoder ждёт 3 конфиг-буфера:
// Не звонить GSM — приоритет медиа // csd-0 OpusHead, csd-1 CodecDelay (int64 LE нс), csd-2 SeekPreRoll (int64 LE нс).
} // Без csd-1/2 декодер берёт мусорные значения из пакетов и молча
// выкидывает весь вывод (mSamplesToDiscard).
format.setByteBuffer("csd-0", ByteBuffer.wrap(opusHead()))
// Прескип 312 сэмплов при 8 кГц = 39 мс = 39_000_000 нс (перекодируется в 48 кГц внутри)
format.setByteBuffer("csd-1", ByteBuffer.wrap(longBytes(39_000_000L)))
format.setByteBuffer("csd-2", ByteBuffer.wrap(longBytes(0L)))
decoder = try { decoder = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also { MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.configure(format, null, null, 0) it.configure(format, null, null, 0)
it.start() it.start()
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "createDecoder failed: ${e.message}")
null null
} }
if (decoder == null) return if (decoder == null) return
val minBuf = AudioTrack.getMinBufferSize( val minBuf = AudioTrack.getMinBufferSize(
SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT AudioFormat.ENCODING_PCM_16BIT
) )
player = AudioTrack( player = try {
AudioManager.STREAM_MUSIC, AudioTrack(
SAMPLE_RATE, AudioManager.STREAM_MUSIC,
AudioFormat.CHANNEL_OUT_MONO, OUT_SAMPLE_RATE,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.CHANNEL_OUT_MONO,
maxOf(minBuf, FRAME_SIZE * 2 * 8), AudioFormat.ENCODING_PCM_16BIT,
AudioTrack.MODE_STREAM maxOf(minBuf, FRAME_SIZE * 2 * 8),
).also { it.play() } AudioTrack.MODE_STREAM
).also { it.play() }
} catch (e: Exception) {
Log.e(TAG, "AudioTrack create failed: ${e.message}")
null
}
headSent = true // csd уходит через формат, не через буферы
drainThread = Thread({ drainLoop() }, "opus-out").also { it.start() }
Log.d(TAG, "decoder started")
}
/** int64 в little-endian. */
private fun longBytes(v: Long): ByteArray =
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array()
/** Отдельный поток: голова + входная очередь + слив вывода. */
private fun drainLoop() {
val dec = decoder ?: return
var dbg = 0
while (decoder != null && decoder === dec) {
try {
// Монотонный pts обязателен: при pts==0 c2.opus.decoder
// считает каждый кадр «перемоткой в ноль» и выбрасывает
// mCodecDelay сэмплов (C2SoftOpusDec.cpp, "When seeking to zero").
var pts = ptsCounter
while (true) {
val pkt = rxQueue.poll() ?: break
val inIdx = dec.dequeueInputBuffer(5000)
if (inIdx < 0) { rxQueue.add(pkt); break }
val buf = dec.getInputBuffer(inIdx) ?: break
buf.clear()
buf.put(pkt)
dec.queueInputBuffer(inIdx, 0, pkt.size, pts, 0)
pts += 20_000L // 20 мс кадра
ptsCounter = pts
if (dbg < 5) { Log.d(TAG, "queued ${pkt.size}B"); dbg++ }
}
var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 1000)
while (outIdx >= 0) {
if (outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
val f = dec.outputFormat
Log.d(TAG, "dec out format: sr=${f.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " +
"ch=${f.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}")
} else {
if (decodeBufferInfo.size > 0) {
val outBuf = ByteBuffer.allocate(decodeBufferInfo.size)
outBuf.order(ByteOrder.LITTLE_ENDIAN)
dec.getOutputBuffer(outIdx)?.also { src ->
src.position(decodeBufferInfo.offset)
src.limit(decodeBufferInfo.offset + decodeBufferInfo.size)
outBuf.put(src)
}
val pcm = outBuf.array()
player?.write(pcm, 0, pcm.size)
onLevel(SRC_SPEAKER, pcmLevel(pcm))
if (dbg < 5) { Log.d(TAG, "out ${pcm.size}B"); dbg++ }
}
dec.releaseOutputBuffer(outIdx, false)
}
outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
}
} catch (e: Exception) {
Log.e(TAG, "drain error: ${e.message}")
}
}
} }
/** Подача одного Opus-пакета на декодер. */ /** Подача одного Opus-пакета на декодер. */
fun onAudioPacket(packet: ByteArray) { fun onAudioPacket(packet: ByteArray) {
val dec = decoder ?: return rxQueue.add(packet)
val track = player ?: return
try {
val inIdx = dec.dequeueInputBuffer(10000)
if (inIdx >= 0) {
val buf = dec.getInputBuffer(inIdx) ?: return
buf.clear()
buf.put(packet)
dec.queueInputBuffer(inIdx, 0, packet.size, 0, 0)
}
var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
while (outIdx >= 0) {
if (decodeBufferInfo.size > 0) {
val outBuf = ByteBuffer.allocate(decodeBufferInfo.size)
outBuf.order(ByteOrder.LITTLE_ENDIAN)
dec.getOutputBuffer(outIdx)?.also { src ->
src.position(decodeBufferInfo.offset)
src.limit(decodeBufferInfo.offset + decodeBufferInfo.size)
outBuf.put(src)
}
val pcm = outBuf.array()
track.write(pcm, 0, pcm.size)
onLevel(SRC_SPEAKER, pcmLevel(pcm))
}
dec.releaseOutputBuffer(outIdx, false)
outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
}
} catch (_: Exception) {
}
} }
fun onAudioFlush() { fun onAudioFlush() {
try { try {
decoder?.flush() decoder?.flush()
player?.flush() player?.flush()
rxQueue.clear()
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
/** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */
private fun opusHead(): ByteArray {
val head = "OpusHead".toByteArray(Charsets.US_ASCII)
val out = ByteArray(19)
System.arraycopy(head, 0, out, 0, head.size)
out[8] = 1 // версия Opus
out[9] = 1 // каналов (моно)
out[10] = 0x38 // pre-skip LE = 312
out[11] = 0x01
out[12] = 0x40 // input sample rate LE = 8000
out[13] = 0x1F
out[14] = 0x00
out[15] = 0x00
out[16] = 0x00 // output gain
out[17] = 0x00
out[18] = 0x00 // channel mapping family = 0
return out
}
/** Остановить декодер и плеер (при отключении). */
fun stopDecoder() {
val dec = decoder
decoder = null // поток разбора выйдет из цикла до release
rxQueue.clear()
if (dec != null) {
try {
dec.stop()
} catch (_: Exception) {
}
try {
dec.release()
} catch (_: Exception) {
}
}
try {
player?.stop()
} catch (_: Exception) {
}
try {
player?.release()
} catch (_: Exception) {
}
player = null
onLevel(SRC_SPEAKER, 0f)
}
// ---------- Захват (TX) ---------- // ---------- Захват (TX) ----------
/** Начать PTT: запустить запись и кодирование. */ /** Начать PTT: запустить запись и кодирование. */
@@ -238,24 +337,7 @@ class OpusEngine(
fun releaseAll() { fun releaseAll() {
stopCapture() stopCapture()
try { stopDecoder()
decoder?.stop()
} catch (_: Exception) {
}
try {
decoder?.release()
} catch (_: Exception) {
}
decoder = null
try {
player?.stop()
} catch (_: Exception) {
}
try {
player?.release()
} catch (_: Exception) {
}
player = null
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
} }

View File

@@ -7,6 +7,7 @@ import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
/** /**
* Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP). * Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP).
@@ -31,6 +32,8 @@ class ReflectorClient(
} }
companion object { companion object {
const val TAG = "SVXRemote"
const val STATE_DISCONNECTED = 0 const val STATE_DISCONNECTED = 0
const val STATE_CONNECTING = 1 const val STATE_CONNECTING = 1
const val STATE_TCP_CONNECTED = 2 const val STATE_TCP_CONNECTED = 2
@@ -45,6 +48,9 @@ class ReflectorClient(
const val TCP_HEARTBEAT_INTERVAL_MS = 2000L const val TCP_HEARTBEAT_INTERVAL_MS = 2000L
const val UDP_HEARTBEAT_INTERVAL_MS = 4000L const val UDP_HEARTBEAT_INTERVAL_MS = 4000L
const val RECONNECT_DELAY_MS = 5000L const val RECONNECT_DELAY_MS = 5000L
const val CLIENT_SW_INFO = "SVXHomyak"
const val CLIENT_SW_VERSION = "1.0.0"
} }
data class Settings( data class Settings(
@@ -350,7 +356,13 @@ class ReflectorClient(
callbacks.onServerInfo(info) callbacks.onServerInfo(info)
state = STATE_READY state = STATE_READY
callbacks.onState(STATE_READY) callbacks.onState(STATE_READY)
// Сначала сообщаем о себе (версия ПО для дашборда), затем
// выбираем TG для передачи и подписываемся на прослушивание
// (иначе сервер не шлёт аудио группы и не показывает
// подписку в статусе: monitoredTGs остаётся пустым).
sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION))
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup)) sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
openUdp(s) openUdp(s)
} }
@@ -471,11 +483,18 @@ 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}")
callbacks.onAudioPacket(audio, seq) callbacks.onAudioPacket(audio, seq)
} else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")
} }
} }
ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> callbacks.onAudioFlush() ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> {
Log.d(TAG, "UDP RX flush")
callbacks.onAudioFlush()
}
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit
else -> Log.d(TAG, "UDP RX type=$type len=${data.size}")
} }
} }

View File

@@ -195,6 +195,12 @@ object MsgBuilder {
return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray()) return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray())
} }
/** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.). */
fun nodeInfo(swInfo: String, swVersion: String): RcMessage {
val json = "{\"swInfo\":\"$swInfo\",\"swVersion\":\"$swVersion\"}"
return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).toByteArray())
}
/** UDP-датаграмма V2: [u16 тип][u16 client_id][u16 seq][payload]. */ /** UDP-датаграмма V2: [u16 тип][u16 client_id][u16 seq][payload]. */
fun udpDatagram(type: Int, clientId: Int, seq: Int, payload: ByteArray = ByteArray(0)): ByteArray { fun udpDatagram(type: Int, clientId: Int, seq: Int, payload: ByteArray = ByteArray(0)): ByteArray {
val w = BinWriter().u16(type).u16(clientId).u16(seq).bytes(payload) val w = BinWriter().u16(type).u16(clientId).u16(seq).bytes(payload)

View File

@@ -19,12 +19,13 @@
<string name="status_connected">На связи</string> <string name="status_connected">На связи</string>
<string name="status_auth_failed">Ошибка авторизации</string> <string name="status_auth_failed">Ошибка авторизации</string>
<string name="status_error">Ошибка: %1$s</string> <string name="status_error">Ошибка: %1$s</string>
<string name="notif_connected">Соединение с рефлектором активно</string>
<string name="btn_connect">Подключиться</string> <string name="btn_connect">Подключиться</string>
<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_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>

View File

@@ -150,22 +150,40 @@ class HandshakeTest {
.u16(1).str(sentCodecs) .u16(1).str(sentCodecs)
.toByteArray() .toByteArray()
writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info)) writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info))
// Client должен прислать MsgSelectTG(11) и UDP-датаграмму // Client должен прислать MsgSelectTG(11), MsgTgMonitor(11) и
// MsgNodeInfo с версией ПО, плюс UDP-датаграмму
var gotTg = false var gotTg = false
while (!gotTg) { var gotMonitor = false
var gotNodeInfo = false
var tries = 0
while ((!gotTg || !gotMonitor || !gotNodeInfo) && tries++ < 100) {
val frame3 = readFrame(input, buf) val frame3 = readFrame(input, buf)
val msg3 = RcMessage.fromPayload(frame3) val msg3 = RcMessage.fromPayload(frame3)
when (msg3.type) { when (msg3.type) {
ReflectorProtocol.MSG_SELECT_TG -> { ReflectorProtocol.MSG_SELECT_TG -> {
val rr = BinReader(msg3.fields) val rr = BinReader(msg3.fields)
assertEquals(11L, rr.u32()) assertEquals(11L, rr.u32())
gotTg = true gotTg = true
sawSelectTg = true sawSelectTg = true
selectTgLatch.countDown() selectTgLatch.countDown()
}
ReflectorProtocol.MSG_TG_MONITOR -> {
val rr = BinReader(msg3.fields)
assertEquals(1, rr.u16())
assertEquals(11L, rr.u32())
gotMonitor = true
}
ReflectorProtocol.MSG_NODE_INFO -> {
val json = BinReader(msg3.fields).str()
assertTrue("JSON: $json", json.contains("swVersion"))
assertTrue("JSON: $json", json.contains("1.0.0"))
gotNodeInfo = true
} }
ReflectorProtocol.MSG_HEARTBEAT -> Unit ReflectorProtocol.MSG_HEARTBEAT -> Unit
} }
} }
assertTrue("TgMonitor не получен", gotMonitor)
assertTrue("NodeInfo не получен", gotNodeInfo)
// Ждём UDP-датаграмму (регистрация UDP) // Ждём UDP-датаграмму (регистрация UDP)
try { try {
val ds = DatagramSocket(server.localPort) val ds = DatagramSocket(server.localPort)

View File

@@ -87,6 +87,27 @@ class ProtocolTest {
assertEquals(11L, r.u32()) assertEquals(11L, r.u32())
} }
@Test
fun `tgMonitor roundtrip`() {
val parsed = RcMessage.tryParseFrame(MsgBuilder.tgMonitor(listOf(11, 22)).toFrame())
assertEquals(ReflectorProtocol.MSG_TG_MONITOR, parsed?.type)
val r = BinReader(parsed!!.fields)
assertEquals(2, r.u16())
assertEquals(11L, r.u32())
assertEquals(22L, r.u32())
}
@Test
fun `nodeInfo roundtrip и версия ПО`() {
val parsed = RcMessage.tryParseFrame(
MsgBuilder.nodeInfo("SVXHomyak", "1.0.0").toFrame()
)
assertEquals(ReflectorProtocol.MSG_NODE_INFO, parsed?.type)
val json = BinReader(parsed!!.fields).str()
assertTrue("JSON: $json", json.contains("\"swInfo\":\"SVXHomyak\""))
assertTrue("JSON: $json", json.contains("\"swVersion\":\"1.0.0\""))
}
@Test @Test
fun `udp audio roundtrip`() { fun `udp audio roundtrip`() {
val audio = ByteArray(30) { (it and 0xFF).toByte() } val audio = ByteArray(30) { (it and 0xFF).toByte() }