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