You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用NAudio流式输出音频出现间隙的问题排查请求

问题描述

我在将软件定义无线电(SDR)捕获的双声道音频流式传输到音频设备时遇到间隙问题:

  • SDR捕获的音频以两个double数组存储(左、右声道各一组),原始采样率2MHz,每组数组长度固定为32768
  • 需要将信号下采样至192kHz后,通过NAudio输出到音频设备
  • 测试发现:单次输出完整信号数组时音频连续无间隙;但拆分多个数组依次输出时,每次传输都会出现音频间隙
问题根源分析

当前实现的核心问题在于每次调用SkimmerOutput都会创建新的WaveOutEvent和StereoSampleProvider实例,并且在前一个实例播放完成后才会初始化下一个实例。这个过程中存在以下导致间隙的环节:

  1. 旧的WaveOutEvent和StereoSampleProvider被销毁时,音频设备会停止输出
  2. 新实例初始化、配置设备、启动播放的过程存在明显时间差,这段时间音频设备无数据输出,从而产生间隙
  3. 下采样逻辑仅直接抽取采样点,可能导致信号不连续,但这不是间隙的主要原因
修复方案:全局缓冲的流式播放模型

核心思路是复用同一个WaveOutEvent和支持动态追加数据的SampleProvider,通过缓冲队列持续给音频设备喂数据,避免重复初始化音频输出组件。

步骤1:实现支持动态追加数据的BufferedStereoSampleProvider

替换原来一次性的StereoSampleProvider,改用可持续接收新数据的缓冲版本:

using System;
using System.Collections.Concurrent;
using NAudio.Wave;

class BufferedStereoSampleProvider : IWaveProvider
{
    private readonly BlockingCollection<Tuple<float[], float[]>> _bufferQueue = new BlockingCollection<Tuple<float[], float[]>>();
    private float[] _currentLeftBuffer;
    private float[] _currentRightBuffer;
    private int _currentBufferPosition;
    private readonly WaveFormat _waveFormat;

    public BufferedStereoSampleProvider(int sampleRate)
    {
        _waveFormat = WaveFormat.CreateIeeeFloatWaveFormat(sampleRate, 2);
    }

    public WaveFormat WaveFormat => _waveFormat;

    // 对外提供的追加数据方法
    public void AddSamples(double[] left, double[] right)
    {
        var leftFloat = Array.ConvertAll(left, d => (float)d);
        var rightFloat = Array.ConvertAll(right, d => (float)d);
        _bufferQueue.Add(Tuple.Create(leftFloat, rightFloat));
    }

    public int Read(byte[] buffer, int offset, int count)
    {
        int bytesWritten = 0;
        int samplesNeeded = count / 8; // 每个立体声样本占8字节(2*4字节float)

        while (samplesNeeded > 0)
        {
            // 当前缓冲为空时,从队列取新数据
            if (_currentLeftBuffer == null || _currentBufferPosition >= _currentLeftBuffer.Length)
            {
                if (!_bufferQueue.TryTake(out var nextBuffer, 100)) // 等待新数据,超时避免卡死
                {
                    break;
                }
                _currentLeftBuffer = nextBuffer.Item1;
                _currentRightBuffer = nextBuffer.Item2;
                _currentBufferPosition = 0;
            }

            // 计算本次能读取的样本数
            int samplesToCopy = Math.Min(samplesNeeded, _currentLeftBuffer.Length - _currentBufferPosition);
            for (int i = 0; i < samplesToCopy; i++)
            {
                BitConverter.GetBytes(_currentLeftBuffer[_currentBufferPosition]).CopyTo(buffer, offset + bytesWritten);
                BitConverter.GetBytes(_currentRightBuffer[_currentBufferPosition]).CopyTo(buffer, offset + bytesWritten + 4);
                bytesWritten += 8;
                _currentBufferPosition++;
                samplesNeeded--;
            }
        }

        return bytesWritten;
    }

    // 清空缓冲,用于停止播放时重置
    public void ClearBuffer()
    {
        _bufferQueue.TryTake(out _, -1);
        _currentLeftBuffer = null;
        _currentRightBuffer = null;
        _currentBufferPosition = 0;
    }
}

步骤2:重构CWSkimmer类,使用全局播放实例

将WaveOutEvent和BufferedStereoSampleProvider改为全局实例,避免重复创建:

using System;
using System.Linq;
using System.Windows.Forms;
using NAudio.Wave;
using System.Threading;

class CWSkimmer
{
    private static WaveOutEvent _waveOut;
    private static BufferedStereoSampleProvider _bufferedProvider;
    private static readonly int _desiredSampleRate = 192000;
    private static readonly int _originalSampleRate = 2000000;

    // 初始化播放组件(只执行一次)
    public static void InitializePlayback(int outputDeviceIndex = 2)
    {
        if (_waveOut != null) return;

        _bufferedProvider = new BufferedStereoSampleProvider(_desiredSampleRate);
        _waveOut = new WaveOutEvent
        {
            DeviceNumber = outputDeviceIndex,
            DesiredLatency = 100 // 调整延迟,平衡响应速度和稳定性
        };
        _waveOut.Init(_bufferedProvider);
        _waveOut.Play();
    }

    public static void SkimmerOutput(double[] leftChannel, double[] rightChannel)
    {
        // 确保播放组件已初始化
        if (_waveOut == null)
        {
            InitializePlayback();
        }

        // 下采样信号
        double[] downsampledLeft = Downsample(leftChannel, _originalSampleRate, _desiredSampleRate);
        double[] downsampledRight = Downsample(rightChannel, _originalSampleRate, _desiredSampleRate);

        // 将数据追加到缓冲队列
        _bufferedProvider.AddSamples(downsampledLeft, downsampledRight);
    }

    static double[] Downsample(double[] signal, int originalSampleRate, int desiredSampleRate)
    {
        int downsamplingFactor = originalSampleRate / desiredSampleRate;
        int newLength = signal.Length / downsamplingFactor;

        double[] downsampledSignal = new double[newLength];

        // 可选优化:用窗口均值代替单点抽取,减少混叠失真
        // for (int i = 0, j = 0; i < signal.Length && j < newLength; i += downsamplingFactor, j++)
        // {
        //     double sum = 0;
        //     for (int k = 0; k < downsamplingFactor && i + k < signal.Length; k++)
        //     {
        //         sum += signal[i + k];
        //     }
        //     downsampledSignal[j] = sum / downsamplingFactor;
        // }
        downsampledSignal[j] = signal[i];

        return downsampledSignal;
    }

    // 停止播放并清理资源
    public static void StopPlayback()
    {
        _waveOut?.Stop();
        _bufferedProvider?.ClearBuffer();
        _waveOut?.Dispose();
        _waveOut = null;
    }

    public static WaveOutCapabilities[] GetOutputDevices()
    {
        return Enumerable.Range(0, WaveOut.DeviceCount)
            .Select(i => WaveOut.GetCapabilities(i))
            .ToArray();
    }
}

步骤3:修改测试代码,适配新的播放逻辑

测试时只需初始化一次,然后连续输出多个数据包:

// 测试代码(无间隙版本)
private void button1_Click(object sender, EventArgs e)
{
    double frequency = 400.0;
    int sampleRate = 2000000;
    double durationSeconds = 5.0;

    double[] generatedSignal = SignalGenerator.GenerateSineWave(frequency, sampleRate, durationSeconds);
    double[] packet1 = new double[5000000];
    double[] packet2 = new double[5000000];

    Array.Copy(generatedSignal, 0, packet1, 0, 5000000);
    Array.Copy(generatedSignal, 5000000, packet2, 0, 5000000);

    // 初始化播放(只需一次)
    CWSkimmer.InitializePlayback(2);
    // 连续输出两个数据包,无间隙
    CWSkimmer.SkimmerOutput(packet1, packet1);
    CWSkimmer.SkimmerOutput(packet2, packet2);
}
额外优化建议
  • 下采样优化:当前直接抽取采样点会导致混叠失真,建议添加低通滤波器后再抽取,或使用均值采样提升音频质量
  • 延迟调整:WaveOutEvent的DesiredLatency参数可在50-200ms区间测试,平衡延迟和播放稳定性
  • 线程安全:若SkimmerOutput从多线程调用(如SDR数据捕获线程),当前的BlockingCollection已保证线程安全
  • 异常处理:添加播放过程中的异常捕获,避免设备断开等问题导致程序崩溃

内容的提问来源于stack exchange,提问作者Tom

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 13:40:55