Add PTT microphone UDP voice test mode
Этот коммит содержится в:
@@ -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() {
|
||||
|
||||
Ссылка в новой задаче
Block a user