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:
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)
|
||||
|
||||
Reference in new issue
Block a user