如何解决 .NET Android 中 AudioTrack 播放 PCM 时的卡顿?
结论
先解决播放任务之间的共享状态问题,再调整缓冲区。当前代码每次都会创建 AudioTrack,但都把实例保存到同一个 _audioTrack 字段;多个任务也会调用同一个 _inner.Render()。连续触发时,这两处设计会让音频输出相互干扰。
对于同一个合成器产生的多个音符,建议使用一个持续运行的渲染循环和一个 AudioTrack。每次触发只提交音符事件,由这个循环处理事件、生成 PCM 并写入输出。能否自然叠加多个音符,仍取决于 MetylSynth 的复音能力和音符管理方式。
当前代码有哪些问题?
_audioTrack 字段会被后续调用覆盖
假设第一次调用创建了轨道 A,第二次调用创建了轨道 B,并把 _audioTrack 改为 B。第一次调用的循环再次执行 _audioTrack.Write(...) 时,也可能写入 B。
因此,每次创建一个实例,不能保证每个任务始终使用自己的实例。轨道赋值和 Play() 也没有受同一把锁保护,实际交错顺序可能更复杂。
如果确实需要每次调用各自播放,至少要将轨道保存在任务内的局部变量中,并始终通过该变量操作轨道。这只能解决轨道引用问题。
多个循环共同推进同一个合成器
left 和 right 是局部数组,但 _inner 是共享实例。多个任务轮流调用同一个 _inner.Render() 时,通常会从同一合成器状态继续生成音频块,而不会各自获得一份独立音符的完整波形。
锁只能串行化这些调用,不能把共享合成器变成多个独立声源。此外,_inner.NoteOn(...) 在锁外执行;如果该合成器没有明确的线程安全保证,它还可能与 Render() 并发访问内部状态。
原始材料不足以确认 MetylSynth 的内部行为,但足以说明这里没有建立独立的渲染状态。
阻塞写入占用了共享锁
Write(short[], int, int) 在流模式下通常会等待数据入队。把它放在 lock(mutex) 中,其他任务等待这把锁时就无法渲染。该重载的长度和返回值单位都是 short 元素数,遇到中断或错误时也可能只写入部分数据。AudioTrack.Write 文档
阻塞写入可以用于控制生产速度,问题在于它与多个竞争任务共用了一把锁。
循环结束后没有释放轨道
CancelAfter(3000) 只会请求取消,不会自动停止或释放 AudioTrack,也不会关闭合成器中的音符。
原代码还忽略了 Task.Run 返回的任务,使调用方难以等待结束或观察异常。持续触发时,应管理好任务、取消源和轨道的生命周期;Release() 用于释放原生音频资源。AudioTrack.Release 文档
如何改成一个输出循环?
保留原有的 AudioAttributes、AudioFormat 和 AudioTrack.Builder 配置,但只在启动音频引擎时创建一次轨道。随后启动一个后台输出任务,按钮或其他调用入口只向队列提交事件。
下面是核心组织方式。示例沿用现有的 _inner.NoteOn() 和 _inner.Render(),假设 Render() 会填满传入的左右声道数组,且 PCM 幅度范围为 [-1, 1]。轨道创建、启动入口和页面生命周期需要接入现有项目。
using System;
using System.Collections.Concurrent;
using System.Threading;
using Android.Media;
private readonly ConcurrentQueue<Action> _audioCommands = new();
// 可重复调用:提交事件,不创建轨道和渲染任务。
public void Play()
{
_audioCommands.Enqueue(() => _inner.NoteOn(0, 60, 100));
}
// 每个音频引擎只运行一个输出循环。
// 此方法接管 track 的使用和释放。
private void RunOutput(AudioTrack track, CancellationToken token)
{
var left = new float[1024];
var right = new float[1024];
var pcm = new short[left.Length * 2];
try
{
track.Play();
while (!token.IsCancellationRequested)
{
while (_audioCommands.TryDequeue(out var command))
{
command();
}
_inner.Render(left, right);
InterlacePcm(left, right, pcm);
int offset = 0;
while (offset < pcm.Length && !token.IsCancellationRequested)
{
int written = track.Write(pcm, offset, pcm.Length - offset);
if (written <= 0)
{
throw new InvalidOperationException(
$"AudioTrack.Write 未取得进展,返回值:{written}");
}
offset += written;
}
}
}
finally
{
try
{
track.Release();
}
finally
{
track.Dispose();
}
}
}
private static void InterlacePcm(
float[] left, float[] right, short[] pcm)
{
if (left.Length != right.Length || pcm.Length != left.Length * 2)
{
throw new ArgumentException("声道数组与 PCM 数组长度不匹配。");
}
for (int i = 0; i < left.Length; i++)
{
pcm[i * 2] =
(short)(Math.Clamp(left[i], -1f, 1f) * short.MaxValue);
pcm[i * 2 + 1] =
(short)(Math.Clamp(right[i], -1f, 1f) * short.MaxValue);
}
}
调用方应保存并观察运行 RunOutput 的后台任务,关闭时请求取消并等待任务结束,再释放合成器。其他对 _inner 的操作也应通过同一输出线程处理,否则共享状态问题仍然存在。
这个示例没有实现音符持续时间。如果需要音符在约三秒后停止,应依据 MetylSynth 实际提供的音符关闭接口调度相应事件。停止整个输出循环和关闭某个音符是两个不同操作。重复触发同一通道、同一音高时能否分别关闭,也需要依据合成器的行为处理。
缓冲区应该怎样设置?
先区分渲染块和轨道内部缓冲区:
left.Length == 1024表示每次生成 1024 个立体声帧。- 交错后的数组有 2048 个
short,对应 4096 字节。 SetBufferSizeInBytes(...)接收字节数,而Write(short[], ...)接收short元素数。
GetMinBufferSize() 返回创建流式轨道所需的估计最小缓冲字节数,并不保证负载下播放平稳。应先检查返回值是否大于零,再根据实际补充数据的频率选择缓冲容量。GetMinBufferSize 文档
原代码的左右声道交错顺序与立体声 PCM16 配置一致,没有明显的排列错误。应确认合成器实际输出采样率与 SAMPLE_RATE 相同,并复用转换数组,避免每个音频块都分配新的 short[]。
修复共享状态后,再测量每次渲染和转换所需的时间。对于每块 N 帧、采样率为 Fs 的音频,可供处理的时间约为 N / Fs 秒。缓冲容量需要容纳处理耗时和调度波动;盲目缩小缓冲会增加欠载风险,过大则会增加交互延迟。
仅凭“大缓冲导致 Android 卡住”的观察还不能确定根因,需要结合线程位置、渲染耗时、异常和设备日志判断。
如果必须让每次调用完全独立播放呢?
每个播放任务需要自己的合成器或独立音源状态、局部 AudioTrack、PCM 缓冲区、取消源和释放流程。仅将 _audioTrack 改成局部变量,仍不足以让共享 _inner 生成相互独立的音频。
对于需要大量重叠音符的合成器,通常更适合先在合成器或混音层合并声部,再通过一个轨道输出。若自行混音,应在浮点域求和并控制增益,最后再转换成 PCM16,避免叠加后削波。
另外,示例中的取消检查不会主动打断已经进入的同步 Write();取消通常要等该次调用返回后才能被循环观察到。需要立即停止时,应另行设计停止与写入之间的同步关系。
备注:内容仅供参考。
