|
|
|
@ -52,6 +52,11 @@ class CallAudioEngine { |
|
|
|
|
|
|
|
|
|
|
|
@Volatile private var muted = false |
|
|
|
@Volatile private var muted = false |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Переключение источника захвата: проводная гарнитура → MIC + wired-устройство, |
|
|
|
|
|
|
|
* иначе VOICE_COMMUNICATION (дефолт). captureLoop сам меняет запись при рассинхроне. */ |
|
|
|
|
|
|
|
@Volatile private var wantedWired = false |
|
|
|
|
|
|
|
@Volatile private var wiredActive = false |
|
|
|
|
|
|
|
|
|
|
|
/* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */ |
|
|
|
/* Для замера задержки AEC (getTimestamp) — счётчики кадров play/capture. */ |
|
|
|
@Volatile private var playFramesWritten = 0L |
|
|
|
@Volatile private var playFramesWritten = 0L |
|
|
|
@Volatile private var capFramesRead = 0L |
|
|
|
@Volatile private var capFramesRead = 0L |
|
|
|
@ -87,11 +92,14 @@ class CallAudioEngine { |
|
|
|
prevSpeaker = am.isSpeakerphoneOn |
|
|
|
prevSpeaker = am.isSpeakerphoneOn |
|
|
|
try { |
|
|
|
try { |
|
|
|
am.mode = AudioManager.MODE_IN_COMMUNICATION |
|
|
|
am.mode = AudioManager.MODE_IN_COMMUNICATION |
|
|
|
am.isSpeakerphoneOn = true |
|
|
|
|
|
|
|
} catch (e: Exception) { |
|
|
|
} catch (e: Exception) { |
|
|
|
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") |
|
|
|
LogManager.addLog("WARN", "CallAudio", "set audio mode failed: ${e.message}") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Маршрут выхода: автоследование за гарнитурой (воткнули → HEADSET, |
|
|
|
|
|
|
|
* вынули → сохранённый маршрут, по умолчанию тихая связь). */ |
|
|
|
|
|
|
|
router.fallbackRoute = if (router.preferredRoute == AudioRoute.SPEAKER) AudioRoute.SPEAKER else AudioRoute.EARPIECE |
|
|
|
|
|
|
|
router.setAutoHeadset(true) |
|
|
|
router.start() |
|
|
|
router.start() |
|
|
|
|
|
|
|
|
|
|
|
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном |
|
|
|
/* AEC: включён только на громкой связи (спикерфон); на гарнитуре/разговорном |
|
|
|
@ -101,20 +109,10 @@ class CallAudioEngine { |
|
|
|
syncAecForRoute(router.route) |
|
|
|
syncAecForRoute(router.route) |
|
|
|
|
|
|
|
|
|
|
|
// ── AudioRecord (захват) ── |
|
|
|
// ── AudioRecord (захват) ── |
|
|
|
val recMin = AudioRecord.getMinBufferSize( |
|
|
|
wiredActive = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null |
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
wantedWired = wiredActive |
|
|
|
audioRecord = try { |
|
|
|
audioRecord = createAudioRecord(wiredActive) |
|
|
|
AudioRecord( |
|
|
|
if (audioRecord == null) { |
|
|
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
|
|
|
|
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, |
|
|
|
|
|
|
|
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) |
|
|
|
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
|
|
|
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed: ${e.message}") |
|
|
|
|
|
|
|
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { |
|
|
|
|
|
|
|
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized") |
|
|
|
|
|
|
|
audioRecord?.release(); audioRecord = null |
|
|
|
|
|
|
|
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false |
|
|
|
NativeLib.callAudioStop(); router.stop(); restoreAudioMode(); return false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -143,6 +141,11 @@ class CallAudioEngine { |
|
|
|
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } |
|
|
|
router.onOutputDevice = { dev -> applyTrackOutputDevice(dev) } |
|
|
|
applyTrackOutputDevice(router.currentOutputDevice) |
|
|
|
applyTrackOutputDevice(router.currentOutputDevice) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Смена маршрута → пересчитать, нужен ли захват с проводной гарнитуры. */ |
|
|
|
|
|
|
|
router.onInputDevice = { _ -> |
|
|
|
|
|
|
|
wantedWired = router.route == AudioRoute.HEADSET && router.wiredInputDevice() != null |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
active = true |
|
|
|
active = true |
|
|
|
audioRecord?.startRecording() |
|
|
|
audioRecord?.startRecording() |
|
|
|
audioTrack?.play() |
|
|
|
audioTrack?.play() |
|
|
|
@ -157,7 +160,7 @@ class CallAudioEngine { |
|
|
|
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */ |
|
|
|
/* Через ~300мс буферы устройства заполнены — замеряем реальную задержку AEC. */ |
|
|
|
mainHandler.postDelayed(aecDelayRunnable, 300) |
|
|
|
mainHandler.postDelayed(aecDelayRunnable, 300) |
|
|
|
|
|
|
|
|
|
|
|
LogManager.addLog("INFO", "CallAudio", "started id=$id recBuf=$recMin trackBuf=$trackBuf") |
|
|
|
LogManager.addLog("INFO", "CallAudio", "started id=$id wired=$wiredActive trackBuf=$trackBuf") |
|
|
|
return true |
|
|
|
return true |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -176,6 +179,7 @@ class CallAudioEngine { |
|
|
|
|
|
|
|
|
|
|
|
NativeLib.callAudioStop() |
|
|
|
NativeLib.callAudioStop() |
|
|
|
router.onOutputDevice = null |
|
|
|
router.onOutputDevice = null |
|
|
|
|
|
|
|
router.onInputDevice = null |
|
|
|
router.stop() |
|
|
|
router.stop() |
|
|
|
restoreAudioMode() |
|
|
|
restoreAudioMode() |
|
|
|
|
|
|
|
|
|
|
|
@ -187,14 +191,37 @@ class CallAudioEngine { |
|
|
|
private fun captureLoop() { |
|
|
|
private fun captureLoop() { |
|
|
|
val buf = ShortArray(FRAME_SAMPLES) |
|
|
|
val buf = ShortArray(FRAME_SAMPLES) |
|
|
|
val silence = ShortArray(FRAME_SAMPLES) |
|
|
|
val silence = ShortArray(FRAME_SAMPLES) |
|
|
|
while (active && audioRecord?.recordingState == AudioRecord.RECORDSTATE_RECORDING) { |
|
|
|
var rec = audioRecord |
|
|
|
|
|
|
|
var frame = 0L |
|
|
|
|
|
|
|
var sinceSwap = 0 |
|
|
|
|
|
|
|
while (active) { |
|
|
|
|
|
|
|
/* проводная гарнитура появилась/исчезла — меняем источник захвата */ |
|
|
|
|
|
|
|
if (wantedWired != wiredActive) { |
|
|
|
|
|
|
|
audioRecord = null /* защита measureAecDelay от UAF на освобождаемой записи */ |
|
|
|
|
|
|
|
rec?.apply { try { stop() } catch (_: Exception) {}; release() } |
|
|
|
|
|
|
|
wiredActive = wantedWired |
|
|
|
|
|
|
|
rec = createAudioRecord(wiredActive) |
|
|
|
|
|
|
|
if (rec == null) return |
|
|
|
|
|
|
|
audioRecord = rec |
|
|
|
|
|
|
|
rec.startRecording() |
|
|
|
|
|
|
|
sinceSwap = 0 |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
val r = rec ?: return |
|
|
|
|
|
|
|
if (r.recordingState != AudioRecord.RECORDSTATE_RECORDING) return |
|
|
|
var read = 0 |
|
|
|
var read = 0 |
|
|
|
while (read < FRAME_SAMPLES) { |
|
|
|
while (read < FRAME_SAMPLES) { |
|
|
|
if (!active) return |
|
|
|
if (!active) return |
|
|
|
val n = audioRecord?.read(buf, read, FRAME_SAMPLES - read) ?: return |
|
|
|
val n = r.read(buf, read, FRAME_SAMPLES - read) |
|
|
|
if (n <= 0) return |
|
|
|
if (n <= 0) return |
|
|
|
read += n |
|
|
|
read += n |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/* диагностика: уровень захваченного сигнала (тишина vs звук) */ |
|
|
|
|
|
|
|
if (sinceSwap < 10 || frame % 100L == 0L) { |
|
|
|
|
|
|
|
LogManager.addLog("WARN", "CallAudio", |
|
|
|
|
|
|
|
"cap wired=$wiredActive frame=$frame rmsDb=${rmsDb(buf)}") |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
sinceSwap++ |
|
|
|
|
|
|
|
frame++ |
|
|
|
val out = if (muted) silence else buf |
|
|
|
val out = if (muted) silence else buf |
|
|
|
capFramesRead += FRAME_SAMPLES |
|
|
|
capFramesRead += FRAME_SAMPLES |
|
|
|
if (!NativeLib.callAudioFeed(callId, out)) { |
|
|
|
if (!NativeLib.callAudioFeed(callId, out)) { |
|
|
|
@ -204,6 +231,50 @@ class CallAudioEngine { |
|
|
|
LogManager.addLog("DEBUG", "CallAudio", "capture loop done") |
|
|
|
LogManager.addLog("DEBUG", "CallAudio", "capture loop done") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** RMS захваченного кадра в dBFS (0 = полная шкала, ниже = тише; тишина ~ -90). */ |
|
|
|
|
|
|
|
private fun rmsDb(buf: ShortArray): Int { |
|
|
|
|
|
|
|
var sum = 0L |
|
|
|
|
|
|
|
for (s in buf) sum += s.toLong() * s.toLong() |
|
|
|
|
|
|
|
val rms = Math.sqrt(sum.toDouble() / buf.size) |
|
|
|
|
|
|
|
return if (rms < 1.0) -120 else (20.0 * Math.log10(rms / 32768.0)).toInt() |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Создать AudioRecord: всегда VOICE_COMMUNICATION (звонковый тракт «запитывает» |
|
|
|
|
|
|
|
* микрофон проводной гарнитуры; AudioSource.MIC на ней даёт тишину). Для проводной |
|
|
|
|
|
|
|
* гарнитуры дополнительно роутим setPreferredDevice на её микрофон. */ |
|
|
|
|
|
|
|
private fun createAudioRecord(wired: Boolean): AudioRecord? { |
|
|
|
|
|
|
|
val recMin = AudioRecord.getMinBufferSize( |
|
|
|
|
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) |
|
|
|
|
|
|
|
val rec = try { |
|
|
|
|
|
|
|
AudioRecord( |
|
|
|
|
|
|
|
MediaRecorder.AudioSource.VOICE_COMMUNICATION, |
|
|
|
|
|
|
|
SAMPLE_RATE, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, |
|
|
|
|
|
|
|
(recMin * 2).coerceAtLeast(FRAME_SAMPLES * 4)) |
|
|
|
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
|
|
|
LogManager.addLog("ERROR", "CallAudio", "AudioRecord create failed (wired=$wired): ${e.message}") |
|
|
|
|
|
|
|
return null |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (rec.state != AudioRecord.STATE_INITIALIZED) { |
|
|
|
|
|
|
|
LogManager.addLog("ERROR", "CallAudio", "AudioRecord not initialized (wired=$wired)") |
|
|
|
|
|
|
|
rec.release() |
|
|
|
|
|
|
|
return null |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
if (wired) { |
|
|
|
|
|
|
|
val dev = router.wiredInputDevice() |
|
|
|
|
|
|
|
val ok = try { |
|
|
|
|
|
|
|
rec.setPreferredDevice(dev) |
|
|
|
|
|
|
|
} catch (e: Exception) { |
|
|
|
|
|
|
|
LogManager.addLog("ERROR", "CallAudio", "record setPreferredDevice(wired) threw: ${e.message}") |
|
|
|
|
|
|
|
false |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
LogManager.addLog("WARN", "CallAudio", |
|
|
|
|
|
|
|
"record input -> VOICE_COMMUNICATION dev=${dev?.type ?: "-"} setPreferredDevice=$ok") |
|
|
|
|
|
|
|
} else { |
|
|
|
|
|
|
|
LogManager.addLog("WARN", "CallAudio", "record input -> VOICE_COMMUNICATION (default)") |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return rec |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|