Add bounded AAudio voice output with AudioTrack fallback

This commit is contained in:
evgeny committed 2026-10-15 14:40:41 +02:00
1 parent a3854f2836
commit 075ffc4d3b
3 files changed
+265 -1

No files matched your search

@@ -0,0 +1,104 @@
package com.utun.chat.data
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRouting
import android.media.AudioTimestamp
import android.media.AudioTrack
import android.os.Build
import android.os.Handler
/** Один выход без промежуточной PCM-очереди. I/O и close сериализованы; владелец — playback worker. */
internal class CallAudioOutput private constructor(
val track: AudioTrack?, private var handle: Long, private val manager: AudioManager
) {
val fast = handle != 0L
val name = if (fast) "AAudio" else "AudioTrack"
private val sample = LongArray(10)
private var accepted = 0L
private var closed = false
val playState: Int get() = track?.playState ?: if (closed) AudioTrack.PLAYSTATE_STOPPED else AudioTrack.PLAYSTATE_PLAYING
val bufferSizeInFrames: Int get() = track?.bufferSizeInFrames ?: sample[4].toInt()
val bufferCapacityInFrames: Int get() = track?.bufferCapacityInFrames ?: sample[5].toInt()
val underrunCount: Int get() = track?.underrunCount ?: sample[3].toInt()
val performanceMode: Int get() = track?.performanceMode ?: AudioTrack.PERFORMANCE_MODE_LOW_LATENCY
val startThresholdInFrames: Int get() = if (track != null && Build.VERSION.SDK_INT >= 31) track.startThresholdInFrames else bufferSizeInFrames
val routedDevice: AudioDeviceInfo? get() = track?.routedDevice ?:
manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { it.id.toLong() == sample[6] }
@Synchronized fun play() { check(!closed); track?.play() }
@Synchronized fun pause() { if (!closed) track?.pause() }
@Synchronized fun write(pcm: ShortArray, offset: Int, count: Int): Int {
check(!closed)
val n = track?.write(pcm, offset, count, AudioTrack.WRITE_NON_BLOCKING) ?: CallOutputNative.write(handle, pcm, offset, count)
if (n > 0) accepted += n
return n
}
val playbackHeadPosition: Int @Synchronized get() = track?.playbackHeadPosition ?:
CallOutputNative.head(handle).coerceIn(0, accepted).toInt()
@Synchronized fun getTimestamp(stamp: AudioTimestamp): Boolean {
if (closed) return false
track?.let { return it.getTimestamp(stamp) }
CallOutputNative.sample(handle, sample)
if (sample[1] < 0 || sample[2] < 0) return false
stamp.framePosition = sample[1]; stamp.nanoTime = sample[2]
return true
}
@Synchronized fun setBufferSizeInFrames(frames: Int): Int {
check(!closed)
track?.let { return it.setBufferSizeInFrames(frames) }
val actual = CallOutputNative.buffer(handle, frames)
CallOutputNative.sample(handle, sample)
return actual
}
@Synchronized fun setStartThresholdInFrames(frames: Int): Int =
if (track != null && Build.VERSION.SDK_INT >= 31) track.setStartThresholdInFrames(frames) else frames
@Synchronized fun setPreferredDevice(device: AudioDeviceInfo?): Boolean = track?.setPreferredDevice(device) ?: true
fun addOnRoutingChangedListener(listener: AudioRouting.OnRoutingChangedListener, handler: Handler) {
track?.addOnRoutingChangedListener(listener, handler)
}
fun removeOnRoutingChangedListener(listener: AudioRouting.OnRoutingChangedListener) {
track?.removeOnRoutingChangedListener(listener)
}
@Synchronized fun release() {
if (closed) return
closed = true
if (handle != 0L) { CallOutputNative.close(handle); handle = 0 }
track?.release()
}
companion object {
fun open(manager: AudioManager, deviceId: Int, tryFast: Boolean): CallAudioOutput {
if (tryFast && Build.VERSION.SDK_INT >= 31) {
val handle = CallOutputNative.open(deviceId)
if (handle != 0L) return CallAudioOutput(null, handle, manager).also { CallOutputNative.sample(handle, it.sample) }
}
val min = AudioTrack.getMinBufferSize(48000, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT)
check(min > 0) { "AudioTrack minimum=$min" }
val track = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH).build())
.setAudioFormat(AudioFormat.Builder().setSampleRate(48000).setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO).build())
.setBufferSizeInBytes((min * 2).coerceAtLeast(3840)).setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY).build()
if (track.state != AudioTrack.STATE_INITIALIZED) { track.release(); error("AudioTrack not initialized") }
LogManager.addLog("INFO", "CallAudio", "output fallback=AudioTrack device=$deviceId tryFast=$tryFast")
return CallAudioOutput(track, 0, manager)
}
}
}
internal object CallOutputNative {
init { System.loadLibrary("utun") }
external fun open(deviceId: Int): Long
external fun write(handle: Long, pcm: ShortArray, offset: Int, count: Int): Int
external fun head(handle: Long): Long
external fun sample(handle: Long, out: LongArray)
external fun buffer(handle: Long, frames: Int): Int
external fun close(handle: Long)
external fun suspendClock(id: Long, epoch: Long)
}