Add PTT microphone UDP voice test mode

Этот коммит содержится в:
Виктор
2026-05-08 01:10:56 +09:00
родитель a267dd0ba9
Коммит acd6995ad0
+252 -26
Просмотреть файл
@@ -1,12 +1,20 @@
package ru.smkdmr.xlxclient package ru.smkdmr.xlxclient
import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -27,15 +35,26 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -48,6 +67,14 @@ class MainActivity : ComponentActivity() {
} }
} }
private const val TEST_HOST = "212.3.149.253"
private const val TEST_PORT = "62030"
private const val TEST_ROOM = "F"
private const val TEST_CALLSIGN = "PHOENIX"
private const val TEST_DMR_ID = "1030217"
private const val AUDIO_SAMPLE_RATE = 8_000
private const val AUDIO_PACKET_SIZE = 320
private val defaultRooms = listOf("A", "B", "C", "D", "E", "F", "G", "H") private val defaultRooms = listOf("A", "B", "C", "D", "E", "F", "G", "H")
@Composable @Composable
@@ -61,16 +88,75 @@ private fun SmkdmrTheme(content: @Composable () -> Unit) {
@Composable @Composable
fun XlxConnectionScreen(modifier: Modifier = Modifier) { fun XlxConnectionScreen(modifier: Modifier = Modifier) {
var host by rememberSaveable { mutableStateOf("xlx.example.org") } val context = LocalContext.current
var port by rememberSaveable { mutableStateOf("40001") } val scope = rememberCoroutineScope()
var room by rememberSaveable { mutableStateOf("A") } val voiceClient = remember { UdpVoiceTestClient() }
var status by rememberSaveable { mutableStateOf("Введите параметры XLX-сервера") }
DisposableEffect(Unit) {
onDispose { voiceClient.stop() }
}
var host by rememberSaveable { mutableStateOf(TEST_HOST) }
var port by rememberSaveable { mutableStateOf(TEST_PORT) }
var room by rememberSaveable { mutableStateOf(TEST_ROOM) }
var callsign by rememberSaveable { mutableStateOf(TEST_CALLSIGN) }
var dmrId by rememberSaveable { mutableStateOf(TEST_DMR_ID) }
var status by rememberSaveable { mutableStateOf("Тестовый профиль готов: $TEST_HOST:$TEST_PORT, комната $TEST_ROOM") }
var isTransmitting by rememberSaveable { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { granted ->
status = if (granted) {
"Микрофон разрешён. Удерживайте PTT для передачи."
} else {
"Нет разрешения на микрофон — PTT не сможет передавать голос."
}
}
)
val portNumber = port.toIntOrNull() val portNumber = port.toIntOrNull()
val hostValid = host.trim().isNotEmpty() val hostValid = host.trim().isNotEmpty()
val portValid = portNumber != null && portNumber in 1..65535 val portValid = portNumber != null && portNumber in 1..65535
val roomValid = room.trim().isNotEmpty() val roomValid = room.trim().isNotEmpty()
val canConnect = hostValid && portValid && roomValid val callsignValid = callsign.trim().isNotEmpty()
val dmrIdValid = dmrId.trim().all(Char::isDigit) && dmrId.trim().isNotEmpty()
val canConnect = hostValid && portValid && roomValid && callsignValid && dmrIdValid
fun startPtt() {
if (!canConnect || portNumber == null) {
status = "Проверьте IP, порт, комнату, позывной и DMR ID."
return
}
val hasMicPermission = context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
if (!hasMicPermission) {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
return
}
isTransmitting = true
status = "PTT ON: микрофон отправляет тестовый PCM UDP поток на ${host.trim()}:$port"
voiceClient.start(
host = host.trim(),
port = portNumber,
room = room.trim().uppercase(),
callsign = callsign.trim().uppercase(),
dmrId = dmrId.trim(),
onError = { message ->
isTransmitting = false
status = message
}
)
}
fun stopPtt() {
voiceClient.stop()
if (isTransmitting) {
status = "PTT OFF: передача остановлена"
}
isTransmitting = false
}
Scaffold { innerPadding -> Scaffold { innerPadding ->
Surface( Surface(
@@ -102,17 +188,12 @@ fun XlxConnectionScreen(modifier: Modifier = Modifier) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("IP или домен XLX") }, label = { Text("IP или домен XLX") },
singleLine = true, singleLine = true,
isError = !hostValid, isError = !hostValid
supportingText = {
if (!hostValid) Text("Укажите адрес сервера")
}
) )
OutlinedTextField( OutlinedTextField(
value = port, value = port,
onValueChange = { input -> onValueChange = { input -> port = input.filter(Char::isDigit).take(5) },
port = input.filter(Char::isDigit).take(5)
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
label = { Text("Порт") }, label = { Text("Порт") },
singleLine = true, singleLine = true,
@@ -128,28 +209,76 @@ fun XlxConnectionScreen(modifier: Modifier = Modifier) {
onRoomSelected = { room = it } onRoomSelected = { room = it }
) )
Spacer(modifier = Modifier.height(2.dp)) OutlinedTextField(
value = callsign,
onValueChange = { callsign = it.uppercase().take(12) },
modifier = Modifier.fillMaxWidth(),
label = { Text("Позывной") },
singleLine = true,
isError = !callsignValid
)
OutlinedTextField(
value = dmrId,
onValueChange = { input -> dmrId = input.filter(Char::isDigit).take(9) },
modifier = Modifier.fillMaxWidth(),
label = { Text("DMR ID") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
isError = !dmrIdValid
)
Button( Button(
onClick = { onClick = {
status = "Готово: ${host.trim()}:$port / комната ${room.trim().uppercase()}" scope.launch {
status = withContext(Dispatchers.IO) {
sendUdpProbe(
host = host.trim(),
port = portNumber ?: 0,
room = room.trim().uppercase(),
callsign = callsign.trim().uppercase(),
dmrId = dmrId.trim()
)
}
}
}, },
enabled = canConnect, enabled = canConnect,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Подключиться") Text("Проверить UDP подключение")
}
Button(
onClick = {},
enabled = canConnect,
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.pointerInput(canConnect) {
detectTapGestures(
onPress = {
startPtt()
tryAwaitRelease()
stopPtt()
}
)
}
) {
Text(if (isTransmitting) "PTT: ПЕРЕДАЧА..." else "Удерживать PTT")
} }
TextButton( TextButton(
onClick = { onClick = {
host = "xlx.example.org" host = TEST_HOST
port = "40001" port = TEST_PORT
room = "A" room = TEST_ROOM
status = "Параметры сброшены" callsign = TEST_CALLSIGN
dmrId = TEST_DMR_ID
status = "Тестовый профиль восстановлен"
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Сбросить") Text("Вернуть тестовые параметры")
} }
} }
} }
@@ -169,7 +298,7 @@ private fun Header() {
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium
) )
Text( Text(
text = "Кастомный Android-клиент для выбора XLX-сервера, порта и комнаты.", text = "PTT тест: микрофон → UDP. Для настоящего DMR нужен AMBE+2 vocoder и MMDVM Homebrew кадры.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -190,16 +319,14 @@ private fun RoomPicker(
) { ) {
OutlinedTextField( OutlinedTextField(
value = selectedRoom, value = selectedRoom,
onValueChange = { value -> onValueChange = { value -> onRoomSelected(value.take(1).uppercase()) },
onRoomSelected(value.take(1).uppercase())
},
modifier = Modifier modifier = Modifier
.menuAnchor() .menuAnchor()
.fillMaxWidth(), .fillMaxWidth(),
label = { Text("Комната / модуль") }, label = { Text("Комната / модуль") },
singleLine = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
supportingText = { Text("Например: A, B, C") } supportingText = { Text("F = тестовый модуль") }
) )
ExposedDropdownMenu( ExposedDropdownMenu(
@@ -234,6 +361,105 @@ private fun StatusBlock(status: String) {
} }
} }
private fun sendUdpProbe(
host: String,
port: Int,
room: String,
callsign: String,
dmrId: String
): String {
return try {
DatagramSocket().use { socket ->
socket.soTimeout = 1_000
val address = InetAddress.getByName(host)
val payload = "SMKDMR-PROBE|CALL=$callsign|ID=$dmrId|ROOM=$room".toByteArray(Charsets.US_ASCII)
socket.send(DatagramPacket(payload, payload.size, address, port))
}
"UDP probe отправлен на $host:$port, комната $room"
} catch (error: Exception) {
"Ошибка UDP probe: ${error.message ?: error::class.java.simpleName}"
}
}
private class UdpVoiceTestClient {
private val running = AtomicBoolean(false)
private var worker: Thread? = null
@SuppressLint("MissingPermission")
fun start(
host: String,
port: Int,
room: String,
callsign: String,
dmrId: String,
onError: (String) -> Unit
) {
if (!running.compareAndSet(false, true)) return
worker = Thread {
var recorder: AudioRecord? = null
try {
val minBuffer = AudioRecord.getMinBufferSize(
AUDIO_SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val bufferSize = maxOf(minBuffer, AUDIO_PACKET_SIZE * 4)
val audioBuffer = ByteArray(AUDIO_PACKET_SIZE)
val address = InetAddress.getByName(host)
var sequence = 0
recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
AUDIO_SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
)
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
onError("AudioRecord не инициализирован. Проверьте разрешение микрофона.")
running.set(false)
return@Thread
}
DatagramSocket().use { socket ->
recorder.startRecording()
while (running.get()) {
val read = recorder.read(audioBuffer, 0, audioBuffer.size)
if (read > 0) {
val header = "SMKDMR-PCM|CALL=$callsign|ID=$dmrId|ROOM=$room|SEQ=${sequence++}|".toByteArray(Charsets.US_ASCII)
val packetBytes = ByteArray(header.size + read)
System.arraycopy(header, 0, packetBytes, 0, header.size)
System.arraycopy(audioBuffer, 0, packetBytes, header.size, read)
socket.send(DatagramPacket(packetBytes, packetBytes.size, address, port))
}
}
}
} catch (error: Exception) {
onError("Ошибка PTT передачи: ${error.message ?: error::class.java.simpleName}")
} finally {
try {
recorder?.stop()
} catch (_: Exception) {
}
recorder?.release()
running.set(false)
}
}.apply {
name = "SMKDMR-PTT-UDP"
start()
}
}
fun stop() {
running.set(false)
worker?.interrupt()
worker = null
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun XlxConnectionScreenPreview() { private fun XlxConnectionScreenPreview() {