refactor: low latency audio processing outside of JNI

This commit is contained in:
Miuzarte
2026-04-14 23:38:19 +08:00
parent c002096a1e
commit 08b996e98c
11 changed files with 233 additions and 25 deletions

View File

@@ -23,7 +23,11 @@
## Features ## Features
- 低延迟音频链路(默认未启用)
- 受控设备播放 `USAGE_MEDIA` 流时([namidaco/namida](https://github.com/namidaco/namida)),两设备的音频延迟只差半拍(没有具体测量能力)
- 受控设备播放 `USAGE_GAME` 流时(明日方舟 Bilibili 服),仍存在 100~200ms 的有感延迟
- 带生物认证的锁屏密码自动填充 (入口位于虚拟按钮中) - 带生物认证的锁屏密码自动填充 (入口位于虚拟按钮中)
- 多配置切换,设备绑定配置,连接后直接进入全屏
- 可替换 scrcpy-server - 可替换 scrcpy-server
- 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口 - 利用 mDNS 服务实现自动连接启用无线调试的设备、自动发现等待配对设备的IP与端口
- 自动横竖屏切换(算吗 - 自动横竖屏切换(算吗

View File

@@ -16,6 +16,5 @@
顺序无关 顺序无关
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
- 横屏布局 - 横屏布局
- I18N - I18N

View File

@@ -40,8 +40,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid" applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 12 versionCode = 13
versionName = "0.1.6" versionName = "0.1.7"
externalNativeBuild { externalNativeBuild {
cmake { cmake {

View File

@@ -1,6 +1,5 @@
package io.github.miuzarte.scrcpyforandroid package io.github.miuzarte.scrcpyforandroid
import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge

View File

@@ -2,16 +2,19 @@ package io.github.miuzarte.scrcpyforandroid.nativecore
// Go reader note: Audio output helper for scrcpy stream: decodes/plays PCM or codec audio frames. // Go reader note: Audio output helper for scrcpy stream: decodes/plays PCM or codec audio frames.
import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFormat import android.media.AudioFormat
import android.media.AudioManager import android.media.AudioManager
import android.media.AudioTrack import android.media.AudioTrack
import android.media.MediaCodec import android.media.MediaCodec
import android.media.MediaFormat import android.media.MediaFormat
import android.os.Build
import android.util.Log import android.util.Log
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.math.max
/** /**
* Decodes and plays scrcpy audio stream (OPUS / AAC / FLAC / RAW PCM). * Decodes and plays scrcpy audio stream (OPUS / AAC / FLAC / RAW PCM).
@@ -19,11 +22,21 @@ import java.nio.ByteOrder
* All [feedPacket] calls are expected from a single background thread. * All [feedPacket] calls are expected from a single background thread.
* [release] may be called from any thread. * [release] may be called from any thread.
*/ */
class ScrcpyAudioPlayer(private val codecId: Int) { class ScrcpyAudioPlayer(
context: Context,
private val codecId: Int,
private val lowLatency: Boolean,
) {
private var mediaCodec: MediaCodec? = null private var mediaCodec: MediaCodec? = null
private var audioTrack: AudioTrack? = null private var audioTrack: AudioTrack? = null
private val bufferInfo = MediaCodec.BufferInfo() private val bufferInfo = MediaCodec.BufferInfo()
private val audioManager = context.applicationContext
.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
private var reusablePcmBuffer = ByteArray(0)
@Volatile
private var audioThreadPriorityApplied = false
@Volatile @Volatile
private var prepared = false private var prepared = false
@@ -34,6 +47,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) { fun feedPacket(data: ByteArray, ptsUs: Long, isConfig: Boolean) {
if (released) return if (released) return
applyAudioThreadPriorityIfNeeded()
if (isConfig) { if (isConfig) {
Log.i( Log.i(
@@ -57,7 +71,13 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
} }
if (codecId == Codec.RAW.id) { if (codecId == Codec.RAW.id) {
ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING) val track = ensureRawAudioTrack() ?: return
track.write(
data,
0,
data.size,
AudioTrack.WRITE_NON_BLOCKING,
)
return return
} }
@@ -155,20 +175,64 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
val minBuf = AudioTrack.getMinBufferSize( val minBuf = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT, SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(1) ).coerceAtLeast(1)
return AudioTrack( val framesPerBurst = audioManager
AudioAttributes.Builder() ?.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)
.setUsage(AudioAttributes.USAGE_MEDIA) ?.toIntOrNull()
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) ?.takeIf { it > 0 }
.build(), ?: DEFAULT_FRAMES_PER_BURST
AudioFormat.Builder() val nativeSampleRate = audioManager
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) ?.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)
.setSampleRate(SAMPLE_RATE) ?.toIntOrNull()
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) ?.takeIf { it > 0 }
.build(), ?: SAMPLE_RATE
(minBuf * 4).coerceAtLeast(65536),
AudioTrack.MODE_STREAM, val bufferSize = if (lowLatency) {
AudioManager.AUDIO_SESSION_ID_GENERATE, val targetBuffer = framesPerBurst * 2 * BYTES_PER_FRAME_PCM16_STEREO
) max(minBuf, targetBuffer)
} else {
(minBuf * 4).coerceAtLeast(DEFAULT_BUFFER_SIZE_BYTES)
}
val attributesBuilder = AudioAttributes.Builder()
.setUsage(
if (lowLatency) AudioAttributes.USAGE_GAME
else AudioAttributes.USAGE_MEDIA
)
.setContentType(
if (lowLatency) AudioAttributes.CONTENT_TYPE_SONIFICATION
else AudioAttributes.CONTENT_TYPE_MOVIE
)
if (lowLatency) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
attributesBuilder.setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_NONE)
}
}
val trackBuilder = AudioTrack.Builder()
.setAudioAttributes(attributesBuilder.build())
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setSampleRate(SAMPLE_RATE)
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build()
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.setSessionId(AudioManager.AUDIO_SESSION_ID_GENERATE)
if (lowLatency)
trackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
val track = trackBuilder.build()
if (lowLatency) {
Log.i(
TAG,
"low-latency audio requested: nativeSampleRate=$nativeSampleRate streamSampleRate=$SAMPLE_RATE " +
"framesPerBurst=$framesPerBurst bufferSize=$bufferSize performanceMode=${track.performanceMode}"
)
}
return track
} }
/** /**
@@ -183,10 +247,15 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
val outBuf = codec.getOutputBuffer(idx) ?: break val outBuf = codec.getOutputBuffer(idx) ?: break
val size = bufferInfo.size val size = bufferInfo.size
if (size > 0) { if (size > 0) {
val pcm = ByteArray(size) ensurePcmBufferCapacity(size)
outBuf.position(bufferInfo.offset) outBuf.position(bufferInfo.offset)
outBuf.get(pcm) outBuf.get(reusablePcmBuffer, 0, size)
track.write(pcm, 0, size, AudioTrack.WRITE_NON_BLOCKING) track.write(
reusablePcmBuffer,
0,
size,
AudioTrack.WRITE_NON_BLOCKING,
)
} }
codec.releaseOutputBuffer(idx, false) codec.releaseOutputBuffer(idx, false)
idx = codec.dequeueOutputBuffer(bufferInfo, 0L) idx = codec.dequeueOutputBuffer(bufferInfo, 0L)
@@ -208,6 +277,22 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
audioTrack = null audioTrack = null
} }
private fun applyAudioThreadPriorityIfNeeded() {
if (!lowLatency || audioThreadPriorityApplied) return
runCatching {
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
audioThreadPriorityApplied = true
}.onFailure {
Log.w(TAG, "set audio thread priority failed", it)
}
}
private fun ensurePcmBufferCapacity(requiredSize: Int) {
if (reusablePcmBuffer.size < requiredSize) {
reusablePcmBuffer = ByteArray(requiredSize)
}
}
private fun longBuffer(value: Long): ByteBuffer = private fun longBuffer(value: Long): ByteBuffer =
ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply { putLong(value); flip() } ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).apply { putLong(value); flip() }
@@ -217,5 +302,8 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
private const val CHANNELS = 2 private const val CHANNELS = 2
private const val CODEC_TIMEOUT_US = 10_000L private const val CODEC_TIMEOUT_US = 10_000L
private const val OPUS_SEEK_PREROLL_NS = 80_000_000L // 80 ms private const val OPUS_SEEK_PREROLL_NS = 80_000_000L // 80 ms
private const val DEFAULT_BUFFER_SIZE_BYTES = 65536
private const val DEFAULT_FRAMES_PER_BURST = 128
private const val BYTES_PER_FRAME_PCM16_STEREO = 4
} }
} }

View File

@@ -222,17 +222,20 @@ fun MainScreen() {
.ifBlank { Scrcpy.DEFAULT_SERVER_VERSION } .ifBlank { Scrcpy.DEFAULT_SERVER_VERSION }
val serverRemotePath = asBundle.serverRemotePath val serverRemotePath = asBundle.serverRemotePath
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
val lowLatency = asBundle.lowLatency
val scrcpy = remember( val scrcpy = remember(
appContext, appContext,
customServerUri, customServerUri,
customServerVersion, customServerVersion,
serverRemotePath, serverRemotePath,
lowLatency,
) { ) {
Scrcpy( Scrcpy(
appContext = appContext, appContext = appContext,
customServerUri = customServerUri, customServerUri = customServerUri,
serverVersion = customServerVersion, serverVersion = customServerVersion,
serverRemotePath = serverRemotePath, serverRemotePath = serverRemotePath,
lowLatency = lowLatency,
).also { ).also {
AppRuntime.scrcpy = it AppRuntime.scrcpy = it
} }

View File

@@ -37,6 +37,7 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker import io.github.miuzarte.scrcpyforandroid.services.AppUpdateChecker
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
@@ -126,6 +127,7 @@ fun SettingsPage(
val snackbar = LocalSnackbarController.current val snackbar = LocalSnackbarController.current
val navigator = LocalRootNavigator.current val navigator = LocalRootNavigator.current
val serverPicker = LocalServerPicker.current val serverPicker = LocalServerPicker.current
val isScrcpyStreaming = AppRuntime.scrcpy?.isStarted() == true
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared) val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
@@ -264,6 +266,18 @@ fun SettingsPage(
item { item {
SectionSmallTitle("投屏") SectionSmallTitle("投屏")
Card { Card {
SwitchPreference(
title = "低延迟音频(实验性)",
summary = "启用后将尝试使用低延迟音频路径" +
"\n推荐配合 RAW PCM 编解码" +
"\n修改后建议划卡重启应用",
checked = asBundle.lowLatency,
onCheckedChange = {
if (!isScrcpyStreaming)
asBundle = asBundle.copy(lowLatency = it)
},
enabled = !isScrcpyStreaming,
)
SwitchPreference( SwitchPreference(
title = "启用调试信息", title = "启用调试信息",
summary = "在全屏界面悬浮显示分辨率、帧率和触点信息", summary = "在全屏界面悬浮显示分辨率、帧率和触点信息",

View File

@@ -18,7 +18,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField

View File

@@ -58,6 +58,7 @@ class Scrcpy(
private val customServerUri: String? = null, private val customServerUri: String? = null,
private val serverVersion: String = DEFAULT_SERVER_VERSION, private val serverVersion: String = DEFAULT_SERVER_VERSION,
private val serverRemotePath: String = DEFAULT_REMOTE_PATH, private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
private val lowLatency: Boolean = false,
) { ) {
private val session = Session() private val session = Session()
@@ -160,7 +161,7 @@ class Scrcpy(
info.audioCodecId.toUInt().toString(16) info.audioCodecId.toUInt().toString(16)
}" }"
) )
val player = ScrcpyAudioPlayer(info.audioCodecId) val player = ScrcpyAudioPlayer(appContext, info.audioCodecId, lowLatency)
audioPlayer = player audioPlayer = player
session.attachAudioConsumer { packet -> session.attachAudioConsumer { packet ->
player.feedPacket(packet.data, packet.ptsUs, packet.isConfig) player.feedPacket(packet.data, packet.ptsUs, packet.isConfig)

View File

@@ -38,6 +38,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("smooth_corner"), booleanPreferencesKey("smooth_corner"),
false, false,
) )
val LOW_LATENCY = Pair(
booleanPreferencesKey("low_latency"),
false,
)
val FULLSCREEN_DEBUG_INFO = Pair( val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"), booleanPreferencesKey("fullscreen_debug_info"),
false false
@@ -122,6 +126,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val smoothCorner by setting(SMOOTH_CORNER) val smoothCorner by setting(SMOOTH_CORNER)
// Scrcpy Settings // Scrcpy Settings
val lowLatency by setting(LOW_LATENCY)
val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO) val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS)
val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON) val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON)
@@ -153,6 +158,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val floatingBottomBar: Boolean, val floatingBottomBar: Boolean,
val floatingBottomBarBlur: Boolean, val floatingBottomBarBlur: Boolean,
val smoothCorner: Boolean, val smoothCorner: Boolean,
val lowLatency: Boolean,
val fullscreenDebugInfo: Boolean, val fullscreenDebugInfo: Boolean,
val showFullscreenVirtualButtons: Boolean, val showFullscreenVirtualButtons: Boolean,
val showFullscreenFloatingButton: Boolean, val showFullscreenFloatingButton: Boolean,
@@ -181,6 +187,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(FLOATING_BOTTOM_BAR) { bundle: Bundle -> bundle.floatingBottomBar }, bundleField(FLOATING_BOTTOM_BAR) { bundle: Bundle -> bundle.floatingBottomBar },
bundleField(FLOATING_BOTTOM_BAR_BLUR) { bundle: Bundle -> bundle.floatingBottomBarBlur }, bundleField(FLOATING_BOTTOM_BAR_BLUR) { bundle: Bundle -> bundle.floatingBottomBarBlur },
bundleField(SMOOTH_CORNER) { bundle: Bundle -> bundle.smoothCorner }, bundleField(SMOOTH_CORNER) { bundle: Bundle -> bundle.smoothCorner },
bundleField(LOW_LATENCY) { bundle: Bundle -> bundle.lowLatency },
bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo }, bundleField(FULLSCREEN_DEBUG_INFO) { bundle: Bundle -> bundle.fullscreenDebugInfo },
bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons }, bundleField(SHOW_FULLSCREEN_VIRTUAL_BUTTONS) { bundle: Bundle -> bundle.showFullscreenVirtualButtons },
bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton }, bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton },
@@ -210,6 +217,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
floatingBottomBar = preferences.read(FLOATING_BOTTOM_BAR), floatingBottomBar = preferences.read(FLOATING_BOTTOM_BAR),
floatingBottomBarBlur = preferences.read(FLOATING_BOTTOM_BAR_BLUR), floatingBottomBarBlur = preferences.read(FLOATING_BOTTOM_BAR_BLUR),
smoothCorner = preferences.read(SMOOTH_CORNER), smoothCorner = preferences.read(SMOOTH_CORNER),
lowLatency = preferences.read(LOW_LATENCY),
fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO), fullscreenDebugInfo = preferences.read(FULLSCREEN_DEBUG_INFO),
showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS), showFullscreenVirtualButtons = preferences.read(SHOW_FULLSCREEN_VIRTUAL_BUTTONS),
showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON), showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON),

View File

@@ -0,0 +1,93 @@
# 低延迟音频_kotlin_side
## 使用“低延迟模式”标志
```kotlin
val audioTrack = AudioTrack.Builder()
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME) // 游戏用途延迟最低
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
// 请求低延迟硬件标志
.setFlags(AudioAttributes.FLAG_LOW_LATENCY)
.build())
.setAudioFormat(AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_16BIT) // TODO: 查看 scrcpy 官方仓库了解实际 PCM 参数 `B:\Git\scrcpy`
.setSampleRate(48000) // 匹配硬件原生采样率
.setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.build())
// 性能模式设置为 LOW_LATENCY
.setBufferSizeInBytes(minBufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
// audioTrack.performanceMode 检查结果
```
## 匹配“原生采样率”与“缓冲区步长”
```kotlin
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
// 获取原生采样率 (通常是 48000)
val sampleRate = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE)?.toInt() ?: 48000
// 获取硬件每秒处理的帧数步长 (Frames Per Burst)
val framesPerBurst = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER)?.toInt() ?: 128
val minBuffer = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_STEREO,
AudioFormat.ENCODING_PCM_16BIT
)
// 16bit 2 bytes
// stereo 2 channels
// 双缓冲 2 个 burst
val targetBuffer = framesPerBurst * 2 * 2 * 2
val bufferSize = max(minBuffer, targetBuffer)
```
## 使用 WRITE_NON_BLOCKING // 已用
## audioTrack 启动预填充静音音频 // 没必要做
## 查看 scrcpy-server 回传的 PCM 具体参数 // 先搁置
如果音频源支持,直接使用 AudioFormat.ENCODING_PCM_FLOAT
## 获取并应用系统“最优缓冲区大小”
```kotlin
// 获取硬件每一跳的帧数 (例如 128, 256 或 512)
val framesPerBurst = audioManager.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER).toInt()
// 缓冲区设置为 2 倍的 Burst 帧数(双缓冲),这是理论上的延迟平衡点
val optimizedBufferSize = framesPerBurst * 2 * (if (isStereo) 2 else 1) * (if (isFloat) 4 else 2)
```
## 绕过音频效果器
```kotlin
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_GAME)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setFlags(AudioAttributes.FLAG_LOW_LATENCY)
// 告诉系统不要过各种 DSP 效果器
.setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_NONE)
.build())
```
## 音频线程优先级
```kotlin
// 在负责读取 Socket 并写入 AudioTrack 的线程内执行
// 单独线程 + 无锁队列,跟网络线程分离
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO)
```
## 缓冲区复用减少 GC
```kotlin
val buffer = ByteArray(FIXED_SIZE)
```