|
|
|
@ -10,6 +10,7 @@ import android.media.MediaRecorder |
|
|
|
import com.utun.chat.ChatApplication |
|
|
|
import com.utun.chat.ChatApplication |
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow |
|
|
|
import kotlinx.coroutines.flow.MutableStateFlow |
|
|
|
import kotlinx.coroutines.flow.StateFlow |
|
|
|
import kotlinx.coroutines.flow.StateFlow |
|
|
|
|
|
|
|
import java.util.concurrent.Executors |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
/** |
|
|
|
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). |
|
|
|
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). |
|
|
|
@ -35,9 +36,9 @@ class RadioAudioEngine { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private var audioRecord: AudioRecord? = null |
|
|
|
@Volatile private var audioRecord: AudioRecord? = null |
|
|
|
private var captureThread: Thread? = null |
|
|
|
private var captureThread: Thread? = null |
|
|
|
private var audioTrack: AudioTrack? = null |
|
|
|
@Volatile private var audioTrack: AudioTrack? = null |
|
|
|
private var playThread: Thread? = null |
|
|
|
private var playThread: Thread? = null |
|
|
|
private var prevMode: Int = AudioManager.MODE_NORMAL |
|
|
|
private var prevMode: Int = AudioManager.MODE_NORMAL |
|
|
|
private var prevSpeaker: Boolean = false |
|
|
|
private var prevSpeaker: Boolean = false |
|
|
|
@ -50,22 +51,45 @@ class RadioAudioEngine { |
|
|
|
private val _talkingFlow = MutableStateFlow(false) |
|
|
|
private val _talkingFlow = MutableStateFlow(false) |
|
|
|
val talkingFlow: StateFlow<Boolean> = _talkingFlow |
|
|
|
val talkingFlow: StateFlow<Boolean> = _talkingFlow |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Сериализованный поток для setup/teardown аудио (не блокирует main). */ |
|
|
|
|
|
|
|
private val io = Executors.newSingleThreadExecutor { r -> |
|
|
|
|
|
|
|
Thread(r, "radio-audio-io").apply { isDaemon = true } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
fun isActive(): Boolean = active |
|
|
|
fun isActive(): Boolean = active |
|
|
|
|
|
|
|
|
|
|
|
fun isTalking(): Boolean = talking |
|
|
|
fun isTalking(): Boolean = talking |
|
|
|
|
|
|
|
|
|
|
|
/** Начать прослушивание канала (переключатель рации вкл). */ |
|
|
|
/** Начать прослушивание канала (переключатель рации вкл). |
|
|
|
fun start(group: Long): Boolean { |
|
|
|
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ |
|
|
|
stop() |
|
|
|
fun start(group: Long) { |
|
|
|
|
|
|
|
if (active && groupId == group) return |
|
|
|
|
|
|
|
active = true |
|
|
|
|
|
|
|
groupId = group |
|
|
|
|
|
|
|
talking = false |
|
|
|
|
|
|
|
io.execute { startBlocking(group) } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fun stop() { |
|
|
|
|
|
|
|
if (!active) return |
|
|
|
|
|
|
|
active = false |
|
|
|
|
|
|
|
talking = false |
|
|
|
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
|
|
|
io.execute { stopBlocking() } |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ── io-поток (сериализовано): setup/teardown аудио ── */ |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private fun startBlocking(group: Long) { |
|
|
|
|
|
|
|
if (!active || groupId != group) return |
|
|
|
|
|
|
|
releaseLocked(false) |
|
|
|
|
|
|
|
|
|
|
|
if (!NativeLib.radioAudioStart(group)) { |
|
|
|
if (!NativeLib.radioAudioStart(group)) { |
|
|
|
|
|
|
|
active = false |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") |
|
|
|
return false |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
groupId = group |
|
|
|
|
|
|
|
talking = false |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val ctx = ChatApplication.instance |
|
|
|
val ctx = ChatApplication.instance |
|
|
|
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|
|
|
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|
|
|
prevMode = am.mode |
|
|
|
prevMode = am.mode |
|
|
|
@ -80,26 +104,26 @@ class RadioAudioEngine { |
|
|
|
// ── AudioRecord (захват) ── |
|
|
|
// ── AudioRecord (захват) ── |
|
|
|
val recMin = AudioRecord.getMinBufferSize( |
|
|
|
val recMin = AudioRecord.getMinBufferSize( |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
audioRecord = try { |
|
|
|
val rec = try { |
|
|
|
AudioRecord( |
|
|
|
AudioRecord( |
|
|
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
|
|
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, |
|
|
|
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) |
|
|
|
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) |
|
|
|
} catch (e: Exception) { |
|
|
|
} catch (e: Exception) { |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}") |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord create failed: ${e.message}") |
|
|
|
NativeLib.radioAudioStop(); restoreAudioMode(); return false |
|
|
|
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return |
|
|
|
} |
|
|
|
} |
|
|
|
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
|
|
|
if (rec.state != AudioRecord.STATE_INITIALIZED) { |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized") |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioRecord not initialized") |
|
|
|
audioRecord?.release(); audioRecord = null |
|
|
|
rec.release() |
|
|
|
NativeLib.radioAudioStop(); restoreAudioMode(); return false |
|
|
|
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// ── AudioTrack (воспроизведение) ── |
|
|
|
// ── AudioTrack (воспроизведение) ── |
|
|
|
val trackMin = AudioTrack.getMinBufferSize( |
|
|
|
val trackMin = AudioTrack.getMinBufferSize( |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) |
|
|
|
val trackBuf = (trackMin * 2).coerceAtLeast(FRAME_SAMPLES * 4) |
|
|
|
audioTrack = try { |
|
|
|
val track = try { |
|
|
|
AudioTrack( |
|
|
|
AudioTrack( |
|
|
|
AudioAttributes.Builder() |
|
|
|
AudioAttributes.Builder() |
|
|
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) |
|
|
|
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) |
|
|
|
@ -110,15 +134,15 @@ class RadioAudioEngine { |
|
|
|
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) |
|
|
|
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) |
|
|
|
} catch (e: Exception) { |
|
|
|
} catch (e: Exception) { |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") |
|
|
|
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") |
|
|
|
audioRecord?.release(); audioRecord = null |
|
|
|
rec.release() |
|
|
|
audioTrack = null |
|
|
|
NativeLib.radioAudioStop(); restoreAudioMode(); active = false; return |
|
|
|
NativeLib.radioAudioStop(); restoreAudioMode(); return false |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
active = true |
|
|
|
audioRecord = rec |
|
|
|
|
|
|
|
audioTrack = track |
|
|
|
micDead = false |
|
|
|
micDead = false |
|
|
|
audioRecord?.startRecording() |
|
|
|
rec.startRecording() |
|
|
|
audioTrack?.play() |
|
|
|
track.play() |
|
|
|
|
|
|
|
|
|
|
|
captureThread = Thread { captureLoop() }.apply { |
|
|
|
captureThread = Thread { captureLoop() }.apply { |
|
|
|
priority = Thread.MAX_PRIORITY; start() |
|
|
|
priority = Thread.MAX_PRIORITY; start() |
|
|
|
@ -128,15 +152,14 @@ class RadioAudioEngine { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") |
|
|
|
LogManager.addLog("INFO", "RadioAudio", "started group=$group recBuf=$recMin trackBuf=$trackBuf") |
|
|
|
return true |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
fun stop() { |
|
|
|
private fun stopBlocking() { |
|
|
|
if (!active) return |
|
|
|
releaseLocked(true) |
|
|
|
active = false |
|
|
|
} |
|
|
|
talking = false |
|
|
|
|
|
|
|
_talkingFlow.value = false |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */ |
|
|
|
|
|
|
|
private fun releaseLocked(resetGroup: Boolean) { |
|
|
|
captureThread?.join(1000); captureThread = null |
|
|
|
captureThread?.join(1000); captureThread = null |
|
|
|
playThread?.join(1000); playThread = null |
|
|
|
playThread?.join(1000); playThread = null |
|
|
|
|
|
|
|
|
|
|
|
@ -148,8 +171,10 @@ class RadioAudioEngine { |
|
|
|
NativeLib.radioAudioStop() |
|
|
|
NativeLib.radioAudioStop() |
|
|
|
restoreAudioMode() |
|
|
|
restoreAudioMode() |
|
|
|
|
|
|
|
|
|
|
|
groupId = 0L |
|
|
|
if (resetGroup) { |
|
|
|
LogManager.addLog("INFO", "RadioAudio", "stopped") |
|
|
|
groupId = 0L |
|
|
|
|
|
|
|
LogManager.addLog("INFO", "RadioAudio", "stopped") |
|
|
|
|
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** PTT зажата — начинаем передачу. */ |
|
|
|
/** PTT зажата — начинаем передачу. */ |
|
|
|
@ -205,16 +230,22 @@ class RadioAudioEngine { |
|
|
|
private fun playLoop() { |
|
|
|
private fun playLoop() { |
|
|
|
val buf = ShortArray(FRAME_SAMPLES) |
|
|
|
val buf = ShortArray(FRAME_SAMPLES) |
|
|
|
val silence = ShortArray(FRAME_SAMPLES) |
|
|
|
val silence = ShortArray(FRAME_SAMPLES) |
|
|
|
|
|
|
|
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */ |
|
|
|
|
|
|
|
var nextNs = System.nanoTime() |
|
|
|
while (active) { |
|
|
|
while (active) { |
|
|
|
val t0 = System.nanoTime() |
|
|
|
|
|
|
|
val track = audioTrack ?: return |
|
|
|
val track = audioTrack ?: return |
|
|
|
val n = NativeLib.radioAudioPull(groupId, buf) |
|
|
|
val n = NativeLib.radioAudioPull(groupId, buf) |
|
|
|
if (n > 0) track.write(buf, 0, n) |
|
|
|
if (n > 0) track.write(buf, 0, n) |
|
|
|
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) |
|
|
|
if (n < FRAME_SAMPLES) track.write(silence, 0, FRAME_SAMPLES - n) |
|
|
|
val elapsedMs = (System.nanoTime() - t0) / 1_000_000 |
|
|
|
|
|
|
|
val sleepMs = FRAME_MS - elapsedMs |
|
|
|
/* абсолютный deadline: не даём Thread.sleep накопить систематический дрейф */ |
|
|
|
if (sleepMs > 0) { |
|
|
|
nextNs += frameNs |
|
|
|
try { Thread.sleep(sleepMs) } catch (_: InterruptedException) { return } |
|
|
|
val waitNs = nextNs - System.nanoTime() |
|
|
|
|
|
|
|
if (waitNs > 0) { |
|
|
|
|
|
|
|
try { Thread.sleep(waitNs / 1_000_000L, (waitNs % 1_000_000L).toInt()) } |
|
|
|
|
|
|
|
catch (_: InterruptedException) { return } |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
nextNs = System.nanoTime() /* отстали — сбрасываем, не копим долг */ |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "play loop done") |
|
|
|
LogManager.addLog("DEBUG", "RadioAudio", "play loop done") |
|
|
|
|