From acd6995ad04e39af4ca5e6abffa2670d2a27110f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= <78488229+viktor138irk@users.noreply.github.com> Date: Fri, 8 May 2026 01:10:56 +0900 Subject: [PATCH] Add PTT microphone UDP voice test mode --- .../java/ru/smkdmr/xlxclient/MainActivity.kt | 278 ++++++++++++++++-- 1 file changed, 252 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/ru/smkdmr/xlxclient/MainActivity.kt b/app/src/main/java/ru/smkdmr/xlxclient/MainActivity.kt index 3c25ede..201f2a3 100644 --- a/app/src/main/java/ru/smkdmr/xlxclient/MainActivity.kt +++ b/app/src/main/java/ru/smkdmr/xlxclient/MainActivity.kt @@ -1,12 +1,20 @@ 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 androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult 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.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -27,15 +35,26 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.tooling.preview.Preview 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() { 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") @Composable @@ -61,16 +88,75 @@ private fun SmkdmrTheme(content: @Composable () -> Unit) { @Composable fun XlxConnectionScreen(modifier: Modifier = Modifier) { - var host by rememberSaveable { mutableStateOf("xlx.example.org") } - var port by rememberSaveable { mutableStateOf("40001") } - var room by rememberSaveable { mutableStateOf("A") } - var status by rememberSaveable { mutableStateOf("Введите параметры XLX-сервера") } + val context = LocalContext.current + val scope = rememberCoroutineScope() + val voiceClient = remember { UdpVoiceTestClient() } + + 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 hostValid = host.trim().isNotEmpty() val portValid = portNumber != null && portNumber in 1..65535 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 -> Surface( @@ -102,17 +188,12 @@ fun XlxConnectionScreen(modifier: Modifier = Modifier) { modifier = Modifier.fillMaxWidth(), label = { Text("IP или домен XLX") }, singleLine = true, - isError = !hostValid, - supportingText = { - if (!hostValid) Text("Укажите адрес сервера") - } + isError = !hostValid ) OutlinedTextField( value = port, - onValueChange = { input -> - port = input.filter(Char::isDigit).take(5) - }, + onValueChange = { input -> port = input.filter(Char::isDigit).take(5) }, modifier = Modifier.fillMaxWidth(), label = { Text("Порт") }, singleLine = true, @@ -128,28 +209,76 @@ fun XlxConnectionScreen(modifier: Modifier = Modifier) { 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( 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, 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( onClick = { - host = "xlx.example.org" - port = "40001" - room = "A" - status = "Параметры сброшены" + host = TEST_HOST + port = TEST_PORT + room = TEST_ROOM + callsign = TEST_CALLSIGN + dmrId = TEST_DMR_ID + status = "Тестовый профиль восстановлен" }, modifier = Modifier.fillMaxWidth() ) { - Text("Сбросить") + Text("Вернуть тестовые параметры") } } } @@ -169,7 +298,7 @@ private fun Header() { style = MaterialTheme.typography.headlineMedium ) Text( - text = "Кастомный Android-клиент для выбора XLX-сервера, порта и комнаты.", + text = "PTT тест: микрофон → UDP. Для настоящего DMR нужен AMBE+2 vocoder и MMDVM Homebrew кадры.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -190,16 +319,14 @@ private fun RoomPicker( ) { OutlinedTextField( value = selectedRoom, - onValueChange = { value -> - onRoomSelected(value.take(1).uppercase()) - }, + onValueChange = { value -> onRoomSelected(value.take(1).uppercase()) }, modifier = Modifier .menuAnchor() .fillMaxWidth(), label = { Text("Комната / модуль") }, singleLine = true, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - supportingText = { Text("Например: A, B, C") } + supportingText = { Text("F = тестовый модуль") } ) 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) @Composable private fun XlxConnectionScreenPreview() {