Radio: stereo capture/playback with mono fallback; CAMCORDER source; bump RADIO_MAX_OPUS

- stereo capture: Android AudioRecord stereo (CAMCORDER source for stereo,
  voice sources fall back to CAMCORDER) + desktop separate miniaudio
  capture/playback devices (no asymmetric duplex)
- Opus stereo encode/decode, voice_jitter stereo (per-channel frame_size,
  slot-based SoundTouch)
- radio_audio_start takes explicit capture_channels (stereo->mono fallback)
- RADIO_MAX_OPUS 256->512 (128kbps stereo frames up to 320B)
- radio_capture_stereo setting + desktop radio settings UI (opus mode/bitrate,
  capture stereo)
- radio_send_to send stats logging (etcp_send ok/fail per second)
This commit is contained in:
evgeny committed 2026-09-29 02:31:41 +03:00
1 parent 331970a121
commit ce1f23fe46
19 files changed
+387 -155

No files matched your search

@@ -59,6 +59,7 @@ class ConfigProvider(private val context: Context) {
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
private val radioOpusModeKey = intPreferencesKey("radio_opus_mode")
private val radioOpusBitrateKey = intPreferencesKey("radio_opus_bitrate")
private val radioCaptureStereoKey = intPreferencesKey("radio_capture_stereo")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@@ -139,6 +140,7 @@ class ConfigProvider(private val context: Context) {
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0
else -> 0
}
}
@@ -173,6 +175,7 @@ class ConfigProvider(private val context: Context) {
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
"radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0
}
}
}
@@ -326,6 +329,23 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[radioMicSourceKey] = source }
}
@Volatile private var radioCaptureStereoCached = 0
@Volatile private var radioCaptureStereoLoaded = false
fun getRadioCaptureStereo(): Boolean {
if (!radioCaptureStereoLoaded) {
radioCaptureStereoCached = runBlocking { context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 }
radioCaptureStereoLoaded = true
}
return radioCaptureStereoCached != 0
}
suspend fun setRadioCaptureStereo(enabled: Boolean) {
radioCaptureStereoCached = if (enabled) 1 else 0
radioCaptureStereoLoaded = true
context.dataStore.edit { prefs -> prefs[radioCaptureStereoKey] = if (enabled) 1 else 0 }
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
@@ -456,6 +476,7 @@ class ConfigProvider(private val context: Context) {
val chatOpusPreset = getInt("chat.opus_codec_preset")
val radioOpusMode = getInt("radio.opus_mode")
val radioOpusBitrate = getInt("radio.opus_bitrate")
val radioCaptureStereo = getInt("radio.capture_stereo")
val chatCompEnabled = getInt("chat.compressor_enabled")
val chatCompMaxGain = getInt("chat.compressor_max_gain_db")
val chatCompRiseRate = getInt("chat.compressor_rise_rate")
@@ -466,6 +487,7 @@ class ConfigProvider(private val context: Context) {
appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("radio_opus_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("radio_capture_stereo=$radioCaptureStereo")
appendLine("compressor_enabled=$chatCompEnabled")
appendLine("compressor_max_gain_db=$chatCompMaxGain")
appendLine("compressor_rise_rate=$chatCompRiseRate")
@@ -159,7 +159,7 @@ object NativeLib {
/* ── Рация (PTT walkie-talkie) ── */
fun radioSetActive(groupId: Long, on: Boolean) { nativeRadioSetActive(groupId, on) }
fun radioSubscribers(groupId: Long): Int = nativeRadioSubscribers(groupId)
fun radioAudioStart(groupId: Long): Boolean = nativeRadioAudioStart(groupId)
fun radioAudioStart(groupId: Long, channels: Int): Boolean = nativeRadioAudioStart(groupId, channels)
fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out)
fun radioAudioStop() { nativeRadioAudioStop() }
@@ -266,7 +266,7 @@ object NativeLib {
/* ── Рация JNI ── */
private external fun nativeRadioSetActive(groupId: Long, on: Boolean)
private external fun nativeRadioSubscribers(groupId: Long): Int
private external fun nativeRadioAudioStart(groupId: Long): Boolean
private external fun nativeRadioAudioStart(groupId: Long, channels: Int): Boolean
private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean
private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int
private external fun nativeRadioAudioStop()
@@ -30,7 +30,10 @@ class RadioAudioEngine {
companion object {
const val SAMPLE_RATE = 48000
const val FRAME_MS = 20
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000 // 960 (на канал)
/** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */
const val PLAY_CHANNELS = 2
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */
const val RELEASE_HOLD_MS = 200L
@@ -53,6 +56,7 @@ class RadioAudioEngine {
@Volatile private var groupId = 0L
@Volatile private var talking = false
@Volatile private var micDead = false
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */
private val _talkingFlow = MutableStateFlow(false)
val talkingFlow: StateFlow<Boolean> = _talkingFlow
@@ -118,12 +122,6 @@ class RadioAudioEngine {
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */
private fun setupAudio() {
val group = groupId
if (!NativeLib.radioAudioStart(group)) {
active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
return
}
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
@@ -135,18 +133,24 @@ class RadioAudioEngine {
LogManager.addLog("WARN", "RadioAudio", "set audio mode failed: ${e.message}")
}
// ── AudioRecord (захват) ──
val recMin = AudioRecord.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT)
val rec = createAudioRecord((recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4))
// ── AudioRecord (захват) с откатом стерео→моно ──
val (rec, capCh) = createAudioRecord()
if (rec == null) {
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return
restoreAudioMode(); active = false; return
}
captureChannels = capCh
if (!NativeLib.radioAudioStart(group, capCh)) {
rec.release()
restoreAudioMode(); active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh")
return
}
// ── AudioTrack (воспроизведение) ──
// ── AudioTrack (воспроизведение, всегда стерео) ──
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT)
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * PLAY_CHANNELS * 4)
val track = try {
AudioTrack(
AudioAttributes.Builder()
@@ -154,7 +158,7 @@ class RadioAudioEngine {
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build(),
AudioFormat.Builder().setSampleRate(SAMPLE_RATE)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build(),
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO).build(),
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
@@ -175,37 +179,62 @@ class RadioAudioEngine {
priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf")
LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf")
}
/* Создание AudioRecord по выбранному источнику; UNPROCESSED с fallback на MIC. */
private fun createAudioRecord(bufferSize: Int): AudioRecord? {
val pref = ChatApplication.instance.configProvider.getRadioMicSource()
/* Создать AudioRecord: сначала желаемые каналы (стерео), при неудаче — откат на моно.
* Источник: выбранный в настройках, UNPROCESSED с fallback на MIC. Возвращает (record, каналы). */
private fun createAudioRecord(): Pair<AudioRecord?, Int> {
val ctx = ChatApplication.instance
val pref = ctx.configProvider.getRadioMicSource()
val wantStereo = ctx.configProvider.getRadioCaptureStereo()
val primary = when (pref) {
1 -> MediaRecorder.AudioSource.MIC
2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
MediaRecorder.AudioSource.UNPROCESSED else MediaRecorder.AudioSource.MIC
3 -> MediaRecorder.AudioSource.VOICE_RECOGNITION
3 -> MediaRecorder.AudioSource.CAMCORDER
else -> MediaRecorder.AudioSource.VOICE_COMMUNICATION
}
val rec = tryCreateRecord(primary, bufferSize)
if (rec != null) { LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$primary"); return rec }
if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
LogManager.addLog("WARN", "RadioAudio", "UNPROCESSED unavailable — fallback to MIC")
return tryCreateRecord(MediaRecorder.AudioSource.MIC, bufferSize)
val sources = if (wantStereo && pref == 0) {
/* voice-источник (AEC/NS/beamforming) коллапсирует в моно/один луч —
* для стерео используем CAMCORDER (тот же тракт, что у видеозаписи). */
LogManager.addLog("WARN", "RadioAudio", "voice source не даёт стерео — использую CAMCORDER")
intArrayOf(MediaRecorder.AudioSource.CAMCORDER)
} else if (pref == 2 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
intArrayOf(primary, MediaRecorder.AudioSource.MIC)
} else {
intArrayOf(primary)
}
return null
val masks = if (wantStereo)
intArrayOf(AudioFormat.CHANNEL_IN_STEREO, AudioFormat.CHANNEL_IN_MONO)
else intArrayOf(AudioFormat.CHANNEL_IN_MONO)
for (mask in masks) {
val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1
val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT)
val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4)
for (src in sources) {
val rec = tryCreateRecord(src, bufSize, mask)
if (rec != null) {
if (wantStereo && channels == 1)
LogManager.addLog("WARN", "RadioAudio", "stereo capture unavailable — fallback to mono")
LogManager.addLog("INFO", "RadioAudio", "AudioRecord source=$src ch=$channels")
return rec to channels
}
}
}
return null to 1
}
private fun tryCreateRecord(source: Int, bufferSize: Int): AudioRecord? {
private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
val rec = try {
AudioRecord(source, SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, bufferSize)
AudioRecord(source, SAMPLE_RATE, channelMask, AudioFormat.ENCODING_PCM_16BIT, bufferSize)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source): ${e.message}")
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed (source=$source chMask=$channelMask): ${e.message}")
return null
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source)")
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized (source=$source chMask=$channelMask)")
rec.release()
return null
}
@@ -272,7 +301,9 @@ class RadioAudioEngine {
}
private fun captureLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val ch = captureChannels
val frame = FRAME_SAMPLES * ch
val buf = ShortArray(frame)
while (active) {
val rec = audioRecord ?: break
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
@@ -283,9 +314,9 @@ class RadioAudioEngine {
break
}
var read = 0
while (read < FRAME_SAMPLES) {
while (read < frame) {
if (!active) return
val n = rec.read(buf, read, FRAME_SAMPLES - read)
val n = rec.read(buf, read, frame - read)
if (n <= 0) {
if (active) {
micDead = true
@@ -303,15 +334,16 @@ class RadioAudioEngine {
}
private fun playLoop() {
val buf = ShortArray(FRAME_SAMPLES)
val silence = ShortArray(FRAME_SAMPLES)
val frame = FRAME_SAMPLES * PLAY_CHANNELS
val buf = ShortArray(frame)
val silence = ShortArray(frame)
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */
var nextNs = System.nanoTime()
while (active) {
val track = audioTrack ?: return
val n = NativeLib.radioAudioPull(groupId, buf)
if (n > 0) track.write(buf, 0, n)
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n)
if (n < frame) track.write(silence, 0, frame - n)
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф */
nextNs += frameNs
@@ -535,7 +535,7 @@ private fun RadioSettingsTab() {
/* ── Источник микрофона ── */
val micSources = listOf(
"Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)",
"Без обработки (UNPROCESSED)", "Распознавание (VOICE_RECOGNITION)")
"Без обработки (UNPROCESSED)", "Камера/видео (CAMCORDER)")
var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) }
var micExpanded by remember { mutableStateOf(false) }
Text("Источник микрофона", style = MaterialTheme.typography.titleMedium)
@@ -557,6 +557,24 @@ private fun RadioSettingsTab() {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Захват стерео ── */
var captureStereo by remember { mutableStateOf(provider.getRadioCaptureStereo()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Захват стерео")
Text("Воспроизведение всегда стерео; моно-пиры апмиксуются",
style = MaterialTheme.typography.bodySmall)
}
Switch(checked = captureStereo, onCheckedChange = {
captureStereo = it
scope.launch { provider.setRadioCaptureStereo(it) }
NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0")
RadioAudioEngine.instance().reconfigure()
})
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Opus для рации: режим + битрейт ── */
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
val opusBitrates = listOf(16, 32, 64, 96, 128)