简介:面向C#/.NET开发者的NAudio音频处理示例工程,演示如何基于音频流数据(而非声卡设备采集)实现录音、播放与实时波形图绘制,为构建音乐编辑器、实时音频分析工具等场景提供可直接借鉴的代码结构。压缩包共171个文件、约3.01MB,以C#源代码(44个cs)为核心,包含WaveInEvent录音、WaveOutEvent播放、GDI+/WPF波形绘制等模块;另含WAV音频样例、DLL依赖库、BAML/XAML界面资源、项目配置文件,覆盖编译运行所需环境。已有6474人学习,资源内部包含从音频数据缓冲、样本归一化到实时更新界面的完整流程,并针对性能优化、内存管理、多线程避免UI阻塞及音频格式转换等常见问题给出处理思路。通过研究示例代码,可掌握NAudio处理音频数据与可视化的关键细节,适合有一定C#基础、希望深入音频开发的中高级开发者。 做C#上位机的朋友多半都撞上过这个需求:录音的同时,界面上要有一根实时跳动的波形线,播放音频文件的时候,同一根线还得跟着走。标题里那句“从音频流数据获取,而非设备获取”看着简单,真动手才知道“波形数据从哪拿”才是整个项目的分水岭。我最初用NAudio搭AudioFileReader加WaveOutEvent的播放链路时,发现播放接口压根不给数据访问入口,想画波形却够不到数据,只能在旁边干瞪眼。
这篇文章就围绕“C#下用NAudio录音、播放音频文件,并实时绘制音频波形图”展开,重点讲清楚为什么波形要从音频流截获,以及怎么在录音和播放两条链路上把样本数据“半路拦截”下来。不管你是做语音采集工具、声波监控上位机,还是简易示波器,这套方案都能直接落地,而且不依赖声卡驱动里那些开关。
1. 为什么坚持从音频流截波形,而不是再开一路录音
1.1 播放场景下,波形数据的“合法来源”并不好找
用NAudio播放一个MP3文件,常规代码就这么几行:
var reader = new AudioFileReader("test.mp3"); var waveOut = new WaveOutEvent(); waveOut.Init(reader); waveOut.Play();这段代码跑起来声音没问题,可WaveOutEvent和AudioFileReader之间是标准的IWaveProvider接口,整个播放过程就是NAudio内部不停地调用Read方法把PCM数据喂给声卡。问题在于:外部代码拿不到这个Read的调用,自然也就拿不到音频样本。很多人第一反应是“那我再加一个录音设备,把声卡输出的声音录下来不就有波形了”,这个方案听着合理,实际是把自己往坑里带。
1.2 从设备取波形的三个硬伤
所谓的“从设备获取波形”,通常是开一个WaveInEvent或者WasapiCapture,去采集声卡回环输出,也就是系统里的“立体声混音”或“播放监听”通道。这个思路有三个绕不开的问题。
第一,设备依赖。很多笔记本声卡、USB声卡默认把立体声混音禁用,甚至驱动里根本没这个通道,程序跑到客户机器上波形就是一片空白,你还没法远程跟客户解释什么是立体声混音。第二,信号污染。回环通道录进来的不光是程序正在播放的声音,系统提示音、后台视频的声音全都会混进来,波形会抖得非常难看。第三,时间戳错位。即便采集通了,回环设备还有一到几十毫秒的缓冲,波形和实际听到的声音总有一段偏移,做精准对齐时特别难受。
1.3 截流方案的完整链路
回头想,录音时波形数据从哪来很明确:WaveInEvent每次抛出DataAvailable事件,缓冲区里就是麦克风采集到的PCM字节,这是音频数据“刚出生”的位置。播放时对应的“出生位置”也一样明确:AudioFileReader把MP3解码成PCM之后、WaveOutEvent把数据交给声卡之前,这一段就是音频流的源头。
所以正确的设计不是“去设备里录播放的声音”,而是“在播放链路上包装一层Provider,把每次Read出来的样本复制一份”。这样录音和播放两条链路,拿到的都是最纯的PCM数据,只是因为来源不同,前者字节序是WaveInEvent给的原始字节,后者是解码后的float数组。
2. 录音模块:在DataAvailable回调里拆样本、写文件、喂波形
2.1 WaveInEvent的基本配置与启动
录音部分用NAudio的WaveInEvent就够了,它是基于Windows waveIn系列API的封装,稳定性对于常规麦克风采集场景完全够用。我通常这样配置:
private WaveInEvent _waveIn; private WaveFileWriter _writer; private bool _isRecording; public void StartRecording(string filePath) { _waveIn = new WaveInEvent { WaveFormat = new WaveFormat(44100, 16, 1), // 44.1kHz, 16bit, 单声道 BufferMilliseconds = 50 // 每个回调携带约50ms数据 }; _waveIn.DataAvailable += OnDataAvailable; _waveIn.RecordingStopped += OnRecordingStopped; _writer = new WaveFileWriter(filePath, _waveIn.WaveFormat); _waveIn.StartRecording(); _isRecording = true; }注意BufferMilliseconds这个参数。它决定DataAvailable事件多久触发一次,触发时携带的字节数就约等于这个时长乘以采样率再乘以位深和声道数。50毫秒是我试过比较舒服的值,回调频率不高不低,波形刷新足够跟手,CPU占用也可控。
2.2 把字节流转成float样本
DataAvailable回调里的e.Buffer是真实采样字节,e.BytesRecorded是本次有效字节数。如果是16位单声道PCM,每个样本占2字节,样本数就是字节数除以2。解析代码如下:
private void OnDataAvailable(object sender, WaveInEventArgs e) { if (!_isRecording) return; _writer.Write(e.Buffer, 0, e.BytesRecorded); int sampleCount = e.BytesRecorded / 2; float[] samples = new float[sampleCount]; for (int i = 0; i < sampleCount; i++) { short raw = (short)(e.Buffer[i * 2] | (e.Buffer[i * 2 + 1] << 8)); samples[i] = raw / 32768f; } PushSamplesToWaveform(samples); }这里把16位整数归一化到-1f到1f之间的float,正好是NAudio内部PCM float的标准范围,后面绘制时直接用绝对值做高度映射,非常方便。位深如果是32位float录音,就用BitConverter.ToSingle,但客制化场景里16位WAV最多,这段代码足够覆盖绝大多数需求。
2.3 边录边写:WaveFileWriter的启用与收尾
WaveFileWriter在开始录音时用目标文件路径和WaveFormat创建,之后每次DataAvailable里直接Write即可,它的内部会自动补WAV头。停止录音时有个顺序问题:先置_isRecording = false,再调用_waveIn.StopRecording(),然后在RecordingStopped事件里做Dispose。
private void OnRecordingStopped(object sender, StoppedEventArgs e) { _writer?.Dispose(); _writer = null; _waveIn?.Dispose(); _waveIn = null; _isRecording = false; }如果不等到RecordingStopped就提前Dispose writer,WAV文件末尾可能缺字节,很多播放器打开会报“文件格式错误”。这个坑我踩过,后面专门在第五节细说。
3. 播放模块:自己封装一层ISampleProvider,半路截获PCM数据
3.1 先看清楚NAudio播放链路的接口形状
播放时想截波形,关键要理解AudioFileReader和WaveOutEvent之间的接口关系。AudioFileReader实现了ISampleProvider和IWaveProvider两套接口,WaveOutEvent接受IWaveProvider。
我第一次尝试是自定义一个IWaveProvider,把AudioFileReader包进去,在Read方法里解析字节。后来发现这有个麻烦:IWaveProvider读写的是byte数组,而AudioFileReader解码出来的float样本经过一次转换才落到byte数组里,我还得根据位深重新拆字节,代码绕一圈,还容易把非16位格式搞错。
3.2 SampleCaptureProvider:一个十行左右的拦截器
更干净的做法是让AudioFileReader以ISampleProvider的身份走float链路,自己写一个包装器在float样本进入转换层之前拦截。下面这个类就是我项目中实际在用的:
public class SampleCaptureProvider : ISampleProvider { private readonly ISampleProvider _source; public SampleCaptureProvider(ISampleProvider source) { _source = source; WaveFormat = source.WaveFormat; } public WaveFormat WaveFormat { get; } public event Action<float[]> SamplesCaptured; public int Read(float[] buffer, int offset, int count) { int read = _source.Read(buffer, offset, count); if (read > 0) { float[] captured = new float[read]; Array.Copy(buffer, offset, captured, 0, read); SamplesCaptured?.Invoke(captured); } return read; } }Read方法返回的read是实际读取到的样本数,不是必需的补零数据。所以拷贝时只用前read个元素,避免把没有有效数据的末尾也塞给绘制层。
3.3 接入播放流程的实际代码
接入之后整个播放链路变成:
private WaveOutEvent _waveOut; private AudioFileReader _reader; private SampleCaptureProvider _capture; public void PlayFile(string filePath) { _reader = new AudioFileReader(filePath); _capture = new SampleCaptureProvider(_reader); _capture.SamplesCaptured += OnPlaybackSamplesCaptured; _waveOut = new WaveOutEvent(); _waveOut.Init(_capture); _waveOut.Play(); } private void OnPlaybackSamplesCaptured(float[] samples) { PushSamplesToWaveform(samples); }这样录音和播放最终都调用同一个PushSamplesToWaveform,在UI层看到的波形数据来源完全统一,不需要区分当前是正在录音还是正在播放。
3.4 为什么选ISampleProvider而不是IWaveProvider
多说一句选型逻辑。ISampleProvider工作在float域,MP3解码、WAV读取、音量控制、混音这些上层操作几乎都在这个域里完成。你把截获层放在这里,看到的样本就是最接近真实音频内容的形态,做峰值、包络、FFT都顺手。IWaveProvider则更接近声卡驱动的原始契约,通常只有做底层封装时才需要碰。
如果你的播放音源不是AudioFileReader,而是内存中的PCM流,只需要让那个流实现ISampleProvider,或者用NAudio里的RawSourceWaveStream包一层,再交给SampleCaptureProvider,一样能截。拦截器的设计思路不受音源格式限制,这是我最喜欢这套方案的一点。
4. 波形图绘制:样本缓冲、坐标映射与刷新节奏
4.1 从一维float数组到二维折线
绘制最朴素的波形,就是把一维样本数组映射到二维坐标系。横轴是时间或画布像素,纵轴是振幅。单声道16位44.1kHz下,一次Read回调会带回来约2000多个样本,如果直接把每个样本画成一个点,性能压力不大,但视觉上全是毛刺。
实际绘图时我用“每列取峰值”的策略:把画布宽度等分为N列,每列对应一段连续的样本区间,取这段里的最大绝对值作为该列的包络值。这样画出来的是上下对称的包络线,视觉清晰,也符合大多数波形编辑器的观感。核心绘制代码类似:
private void RenderWaveform(Graphics g, float[] samples, int width, int height) { if (samples.Length == 0) return; int step = Math.Max(1, samples.Length / width); Point[] points = new Point[width]; int midY = height / 2; for (int x = 0; x < width; x++) { float max = 0f; for (int j = x * step; j < (x + 1) * step && j < samples.Length; j++) max = Math.Max(max, Math.Abs(samples[j])); int y = (int)(midY * (1f - max)); points[x] = new Point(x, y); } g.DrawLines(_pen, points); }如果你想看到更“示波器”的实时感,可以把上面取峰值改成直接在中间填满振幅线段,也就是从midY - max * midY画到midY + max * midY,一眼看过去就是一根实心带,现场感和可视化效果都很强。
4.2 线程安全的样本缓冲与UI节流
音频回调和绘制绝不能放在同一个线程。DataAvailable和SamplesCaptured都跑在NAudio内部线程上,频率是每秒20次左右的回调,每次都直接操作UI控件,跨线程异常和界面卡顿都会找上门。
我的做法是用一个带锁的List当共享缓冲区,音频线程往里追加,UI线程定时取走并清空。取走时用Interlocked.Exchange换出一个新List,最大化地减少锁竞争。
private readonly object _syncRoot = new object(); private List<float> _sampleBuffer = new List<float>(); private void PushSamplesToWaveform(float[] samples) { lock (_syncRoot) { _sampleBuffer.AddRange(samples); if (_sampleBuffer.Count > 44100 * 5) // 最多缓存5秒 _sampleBuffer.RemoveRange(0, _sampleBuffer.Count - 44100 * 5); } } private void TimerTick(object sender, EventArgs e) { List<float> renderSamples; lock (_syncRoot) { renderSamples = _sampleBuffer; _sampleBuffer = new List<float>(); } Invalidate(); // 触发OnPaint重绘 }UI侧用System.Windows.Forms.Timer定时器,间隔设30毫秒左右,也就是33帧每秒,视觉上足够流畅。很多人会贪心把间隔压到10毫秒,结果CPU直升,画面反而因为频繁重绘而闪烁。30毫秒是我实测的甜点值。
4.3 峰值归一化与自动缩放
固定用1f做振幅上限会有一个问题:房间里环境音量很小时,整条波形只有一条细线,看着像没信号。我后来加了自动增益:每次取渲染样本时,先扫描当前缓冲里的最大峰值,如果峰值低于0.5f,就按峰值归一化到0.5f的位置,如果峰值超过1f——比如突然对着麦克风吼一声——就回退到1f上限。这样波形始终稳定在画布中间区域,不会出现“要么贴顶要么贴地”的极端情况。
归一化只影响显示,不影响写入WAV文件的原始数据,因为文件写入在更早的数据回调阶段就完成了。
5. 踩坑记录:线程回调、GC压力和文件收尾
5.1 DataAvailable与UI跨线程,别用Control.Invoke硬扛
录音回调线程里直接调用this.Invoke更新一个Label、一个PictureBox,数据量小的时候看着没事,一旦回调密集上来,界面会肉眼可见地僵硬。因为每一次Invoke都是在UI消息循环里插队,几十次插队叠加,鼠标移动都变卡。
我后来的统一策略是:所有音频线程都只写缓冲对象,所有UI操作都只在定时器里做。定时器本身就是UI线程创建的,所以TimerTick里直接操作控件完全合法。这样整个项目只有一个地方碰UI,思路干净,也不会把线程问题散得到处都是。
5.2 PlaybackStopped回调与按钮点击的交叉死锁
播放停止事件PlaybackStopped是在播放线程回调的,不是在UI线程。有个典型错误是这样写的:
private void StopButton_Click(object sender, EventArgs e) { _waveOut.Stop(); // 内部会触发PlaybackStopped // 然后立即用 ManualResetEvent 等待事件完成 }如果那个ManualResetEvent的Wait在UI线程上,而PlaybackStopped回调里又去执行this.Invoke准备更新UI,两边就会互相等,直接卡死界面。正确做法是停止时通过_waveOut.Stop()触发回调,在PlaybackStopped里处理audio reader的Dispose和UI状态切换,全程不要再用Invoke跨线程。
5.3 高频回调时的数组分配与GC抖动
SampleCaptureProvider每次Read都new float[read],WaveInEvent那边每次也new float[sampleCount]。单看一次分配不算什么,但每秒20次回调,每次几千个float,长时间录音下来GC压力真的会反映在波形拖尾上。
优化方案有两种,我配合着用。第一种是复用数组,在类里预分配一个足够大的float数组,每次解析后往共享缓冲写,写完不需要保留引用;第二种是在SampleCaptureProvider里做个小池子,把释放的数组存起来下次再用。对于一般几千样本的缓冲,第一种就够用了,代码也更简单。
5.4 录音停止时wave文件结尾不完整
这个坑几乎每个人都会踩一次。直接点停止按钮,然后立刻拿到文件去播放,会发现时长比实际少几百毫秒或者直接打不开。原因是WaveFileWriter的WAV头里要写数据块长度,必须在文件关闭前更新;更常见的原因是你还没等最后的DataAvailable把剩余缓冲写完就把writer Dispose了。
我现在的标准停止流程是:设置_isRecording = false,调用_waveIn.StopRecording(),在RecordingStopped事件里才Dispose writer并置空引用。严格按这个顺序,WAV文件一次都没坏过。
另外提一点:采样率不要为了省事写死成8000,语谱图和波形绘制都会受影响,做通用采集工具直接用44100或48000,兼容性最好。
音频波形这个功能看起来小,一旦把数据链路理清楚,后面扩展空间其实很大。把SampleCaptureProvider里截获的样本继续接出去,就能直接叠加FFT频谱、音高检测、触发词识别。我在自己的上位机项目里就是同一份数据同时喂给波形图和频谱图,录音、播放、分析三路共用,没有额外增加一条采集通道。按本文这套“从流截取”的思路实现,性能和数据一致性都有了保证,遇到新格式或新音源,只要把Provider包装一层就能继续复用。
本文还有配套的精品资源,点击获取