简介:面向C#/.NET开发者,压缩包围绕NAudio开源库演示完整的音频采集、播放与波形可视化流程。示例工程涵盖WaveIn录音、WaveOut播放以及WPF界面中基于Canvas和DrawingContext实时绘制音频波形,代码清晰拆分为录音控制器、播放控制器与自定义波形图控件,并附带30个wav测试音频,可直接运行体验。包内共171个文件、约3.72MB,以44个cs源码、13个dll依赖库、9个baml与4个xaml界面资源、8个txt说明文件为主体,配合pdb调试符号、exe可执行文件等,目录结构完整紧凑,便于从源码、界面布局到运行文件全链路对照学习。已有3240人浏览学习,适合想掌握NAudio音频处理、或需要在WPF中实现录音播放与信号可视化的初级和中级开发者。压缩包内的示例在Visual Studio中打开即可运行,录音模块的DataAvailable数据回调、Dispatcher线程更新UI、波形坐标归一化映射等关键逻辑均有实现参考;遇到缓冲、播放线程或界面刷新问题时,可直接对照代码排查,节省自行摸索的时间,并在此基础上扩展出更复杂的音频处理工具。 很多做 C# 上位机的朋友,迟早都会碰到音频处理的需求。不管是语音对讲、录音存档,还是做个简单的声学检测工具,第一步基本都是“把声音采进来,再放出去”,如果能顺手把波形画出来,调试时心里会踏实很多。NAudio 算是 C# 生态里绕不开的音频库,这次我就把用 NAudio 实现录音、播放和实时波形绘制的完整方案拆开讲清楚,代码可以直接抄。
这个项目适合谁看?如果你正在做 WinForms/WPF 桌面工具,需要接麦克风录音、播放 WAV/MP3 音频,或者想让采集到的音频数据可视化,那这篇文章就是给你准备的。我会从录音模块怎么搭、播放缓冲怎么填,到波形图怎么画不卡顿,把每一步的原理和坑都说透。
1. 需求拆解与方案选型
1.1 核心需求与整体思路
这个项目看着名字挺长,其实需求就三条:
- 用 C# 调用系统音频设备采集麦克风数据,保存成 WAV 文件
- 能播放本地音频文件,或者播放刚才录下的内容
- 在录音或播放过程中,把音频数据的波形实时画到界面上
我第一次接这类需求时,下意识想的是“用 Windows 底层 API 写音频采集”,后来发现完全没必要。NAudio 把这些底层逻辑都封装好了,WaveInEvent 负责采集,WaveOutEvent 负责播放,WaveFileWriter 负责写 WAV 文件,BufferedWaveProvider 管缓冲,整条链路清晰得很。
整体架构是这样的:麦克风数据通过 WaveInEvent 的 DataAvailable 事件源源不断抛出来,我们要做的核心事情就是接住这些字节数据,一 份写入文件,一份塞进播放缓冲,一份送给 UI 线程画波形。听起来简单,但每个环节都有不少细节,特别是缓冲大小和实时绘制的性能,处理不好就会出现爆音和界面卡顿。
1.2 为什么选择 NAudio
现在 C# 做音频的方案不少,我对比过几个:
- NAudio:开源、维护活跃、API 覆盖面广,录音、播放、混音、效果处理、波形可视化都有现成的类,社区资料多,遇到问题容易找到答案。这是它最大的优势。
- Windows Media Foundation(WinRT API):系统自带,但 API 风格和传统 WinForms 不太搭,封装层次高,反而不好控制底层音频流。
- 音频采集SDK(商业):功能强,但收费,而且对简单项目来说属于杀鸡用牛刀。
- 直接 P/Invoke 调用 waveIn/waveOut 系列 API:性能最好,但工作量巨大,错误处理全靠自己,除非有特殊需求,否则不推荐。
一句话:选 NAudio 是因为它在“功能完整度”和“开发效率”之间找了个极好的平衡点。我自己做视频会议客户端时,用 NAudio 处理回声消除和混音,也没遇到性能瓶颈。
2. 录音模块设计与实现
2.1 WaveInEvent 录音机制与参数选择
录音的核心是 WaveInEvent 类,它的工作模式是:你告诉它用什么格式采集、数据往哪放,它启动后台线程,循环从音频设备读取数据,然后通过 DataAvailable 事件回调给你。
有几个关键参数要提前定清楚:
- 采样率(Sample Rate):8000Hz 语音就够了,16000Hz 音质更好,44100Hz 是 CD 音质。我习惯用 16000Hz 作为默认值,语音类应用完全够用,数据量也小。如果你做音乐类工具,建议 44100Hz。
- 位深度(Bit Depth):16bit 是标准选择,每个采样点占 2 字节,计算方便,NAudio 的 WaveFileWriter 对 16bit PCM 支持也最好。
- 声道数(Channels):1 声道(单声道)满足大多数场景,数据量减半。
对应的 WaveFormat 初始化代码:
WaveFormat waveFormat = new WaveFormat(16000, 16, 1); // 参数依次是:采样率、位深度、声道数接下来是 WaveInEvent 的配置:
WaveInEvent waveIn = new WaveInEvent(); waveIn.DeviceNumber = 0; // 麦克风设备索引,一般不用改 waveIn.WaveFormat = waveFormat; waveIn.BufferMilliseconds = 50; // 缓冲区大小,单位毫秒,50ms 是一个比较稳的平衡点 waveIn.DataAvailable += OnDataAvailable; waveIn.RecordingStopped += OnRecordingStopped;BufferMilliseconds 这个参数很容易被忽略,但它直接决定了回调的触发频率和稳定性。设得太小(比如 10ms),回调太频繁,CPU 占用高;设得太大(比如 200ms),录音延迟大,波形绘制响应也会迟钝。实测下来 50ms 是录音、播放、绘制三者兼顾的甜点值。
启动和停止录音的代码:
waveIn.StartRecording(); // 启动录音 // ... waveIn.StopRecording(); // 停止录音(触发 RecordingStopped 事件) // waveIn.Dispose(); // 彻底释放资源注意 StopRecording 是异步的,调用后 RecordingStopped 事件中做资源释放和文件收尾更稳妥。
2.2 DataAvailable 事件与文件写入
DataAvailable 事件是整个录音功能的发动机。每次触发时,参数 e.Buffer 里就是一段原始 PCM 数据,e.BytesRecorded 是这段数据的有效字节数。
我最初的写法是把接收到的数据直接追加到文件,但后来发现一个坑:如果你在事件回调里做了太多事(比如写文件的同时又去更新 UI),会造成音频数据积压,回调触发延迟,最终导致录音内容变质。所以我的做法是:回调里只做两件事——写文件和塞数据流,UI 更新全部放到界面重绘阶段去拉取数据。
先把录音文件写入封装好:
WaveFileWriter writer = null; private void OnDataAvailable(object sender, WaveInEventArgs e) { // 第一次触发时初始化写入器(也可以在点击"开始录音"时提前创建) if (writer == null) { writer = new WaveFileWriter("recorded.wav", waveIn.WaveFormat); } // 把本次回调收到的数据写入文件 writer.Write(e.Buffer, 0, e.BytesRecorded); // 同时把数据交给播放缓冲和波形绘制模块(下一节详述) PlayBufferManager.AddSamples(e.Buffer, e.BytesRecorded); WaveformProcessor.PushData(e.Buffer, e.BytesRecorded); }这里有个小习惯:WaveFileWriter 只需要创建一次,不要每次回调都 new。Write 方法内部自带缓冲区,性能没问题。
停止录音后,记得关闭文件流并释放 writer:
private void OnRecordingStopped(object sender, StoppedEventArgs e) { if (writer != null) { writer.Dispose(); writer = null; } waveIn.Dispose(); }2.3 录音模块完整封装示例
把上面串起来,一个可直接复用的录音模块就成形了:
public class AudioRecorder { private WaveInEvent _waveIn; private WaveFileWriter _writer; public event EventHandler<WaveInEventArgs> DataReceived; public void Start(string filePath, int sampleRate = 16000, int channels = 1) { _waveIn = new WaveInEvent(); _waveIn.DeviceNumber = 0; _waveIn.WaveFormat = new WaveFormat(sampleRate, 16, channels); _waveIn.BufferMilliseconds = 50; _waveIn.DataAvailable += (s, e) => { _writer?.Write(e.Buffer, 0, e.BytesRecorded); DataReceived?.Invoke(this, e); }; _waveIn.RecordingStopped += (s, e) => { _writer?.Dispose(); _writer = null; _waveIn.Dispose(); }; _writer = new WaveFileWriter(filePath, _waveIn.WaveFormat); _waveIn.StartRecording(); } public void Stop() { _waveIn?.StopRecording(); } }实际使用中,我发现一个很实用的细节:录音过程中如果界面程序崩溃退出,WAV 文件可能会损坏。为了解决这个问题,我会在程序异常退出时,先调用 Stop 再 Dispose,确保文件头信息完整写入。这也是所有音频采集程序上线前一定要测的异常场景。
3. 播放模块与数据缓冲设计
3.1 BufferedWaveProvider:播放的"蓄水池"
播放音频文件,NAudio 里最直接的方式是 AudioFileReader + WaveOutEvent,比如:
WaveOutEvent waveOut = new WaveOutEvent(); AudioFileReader fileReader = new AudioFileReader("recorded.wav"); waveOut.Init(fileReader); waveOut.Play();这个方法对“播放本地文件”这种一次性场景很合适。但我们的项目需求是“播放刚才录下的音频”,而且希望录音时能实时监听(也就是边录边放),那就不能只用 AudioFileReader,因为音频数据是动态产生的,不是从文件读取。
此时 BufferedWaveProvider 就派上用场了。它的作用是一个音频“蓄水池”:你把 PCM 数据往里塞,WaveOutEvent 从里面取数据播放,池子满了或空了都有对应行为。
BufferedWaveProvider bufferProvider; // 初始化播放设备 WaveOutEvent waveOut = new WaveOutEvent(); bufferProvider = new BufferedWaveProvider(waveFormat); // 波形格式必须与录制格式一致 bufferProvider.BufferDuration = TimeSpan.FromMilliseconds(500); // 缓冲时长,500ms足够 bufferProvider.DiscardOnBufferOverflow = true; // 缓冲满时丢弃旧数据,而不是停止播放 waveOut.Init(bufferProvider); waveOut.Play();录制回调中把数据塞进播放缓冲:
private void OnDataAvailable(object sender, WaveInEventArgs e) { bufferProvider.AddSamples(e.Buffer, 0, e.BytesRecorded); }这里有个很重要的设计选择:BufferDuration 设多大?设小了,播放过程中数据跟不上就会出现“咔咔”的断音;设大了,实时监听延迟明显。实测下来,500ms 到 1s 之间,听感延迟可以接受,也不容易断流。DiscardOnBufferOverflow 建议设为 true,尤其当你边录边播时,播放速度略慢于录音速度,缓冲很快被塞满,如果默认不丢弃旧数据,播放就会一直卡在老数据上。
3.2 播放文件与录音实时监听的共存策略
如果你做的是“播放本地音频文件 + 同时录音”,情况会稍有不同。这时有两个独立的数据流:文件流是只读播放,麦克风流是录制。最简单的方案是各用一个 WaveOutEvent,但更优雅的做法是:文件播放走 AudioFileReader,录制走 WaveInEvent,两条链路互不干扰,因为它们的音频格式可能不同(文件是 44100Hz 立体声,录制是 16000Hz 单声道),不能共用一个 BufferedWaveProvider。
如果你有混音需求(把文件背景音和麦克风声音混在一起输出),那就要用 NAudio 的 MixingWaveProvider32 了,这属于进阶玩法,本次暂不展开。至少在本例的边录边放场景中,共用一个 BufferedWaveProvider 就够了。
4. 波形图实时绘制:从字节流到可见曲线
4.1 波形绘制的数据链路设计
波形图是很多人容易卡住的地方。直接把人眼所见的数据流都画出来,性能一定崩。你需要建立一条压缩的数据链路:
麦克风 PCM 字节流 → 短整型采样点序列 → 纵向峰值缩减 → 可视宽度采样点 → GDI+ 绘制折线
关键优化是纵向峰值缩减:一屏比如显示 2000 个像素点,但录音 50ms 回调一次就产生 1600 个采样点(16000Hz * 0.05s),如果每秒刷新 20 次,数据量是每秒 32 万采样点,全部逐点绘制肯定卡顿。
所以正确的做法是:把收到的采样点先暂存到一个大的环形缓冲中,绘制时,把缓冲中某一时间段的全部采样点缩放到屏幕宽度的像素数,每个像素列取该列所有采样点的最大幅值,作为该列的波形高度。
4.2 数据处理三个核心环节
环节一:字节数据转采样点
16bit PCM 数据每个采样点是 2 字节,小端序。转换代码:
public short[] ConvertBytesToSamples(byte[] data, int byteCount) { int sampleCount = byteCount / 2; short[] samples = new short[sampleCount]; for (int i = 0; i < sampleCount; i++) { samples[i] = BitConverter.ToInt16(data, i * 2); } return samples; }实际项目中我不会直接用 BitConverter,性能不够好,会用 Buffer.BlockCopy 配合 unsafe 指针操作。不过为了可读性,上面这种写法也够用。
环节二:采样点峰值压缩
假设画布宽 800 像素,要显示最近一秒的数据(16000 个采样点),平均每个像素宽要容纳 20 个采样点。我们要取这 20 个点里的最大绝对值:
public int[] PeakCompress(short[] samples, int width) { int[] peaks = new int[width]; int samplesPerPixel = samples.Length / width; if (samplesPerPixel < 1) samplesPerPixel = 1; for (int x = 0; x < width; x++) { short max = 0; int start = x * samplesPerPixel; int end = Math.Min(start + samplesPerPixel, samples.Length); for (int i = start; i < end; i++) { short absValue = (short)Math.Abs(samples[i]); if (absValue > max) max = absValue; } peaks[x] = max; } return peaks; }这样每个像素列只画一条垂直线段,计算量从 16000 次降到 800 次,性能提升不是一点半点。
环节三:GDI+ 绘制波形
拿到峰值数组后,绘制就很简单了:
protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.Clear(Color.Black); if (_peaks == null || _peaks.Length == 0) return; int midY = Height / 2; using (Pen pen = new Pen(Color.LimeGreen, 1f)) { for (int x = 0; x < _peaks.Length - 1; x++) { int y1 = midY - _peaks[x] * (Height / 2 - 10) / 32768; int y2 = midY - _peaks[x + 1] * (Height / 2 - 10) / 32768; g.DrawLine(pen, x, y1, x + 1, y2); } } }这里有三个细节:
- 除以 32768 是因为 16bit 有符号数的最大值是 32767,这样能把幅值归一化到画布高度范围。
- 绘制时用折线比用垂直线段好看,也更接近传统示波器效果。
- GDI+ 的 DrawLine 在小尺寸图形上是够用的,不需要考虑 GPU 加速。
4.3 UI 线程刷新与避免闪烁
实时更新 UI 的核心机制是:数据到达 → 存入缓冲 → 请求重绘。这里最忌讳的是在 DataAvailable 回调里直接调用绘图,因为音频线程不是 UI 线程,而且高频绘图会卡死界面。
推荐做法一:在 DataAvailable 中 PushData 后调用 Invalidate()。
public void PushData(byte[] data, int byteCount) { short[] samples = ConvertBytesToSamples(data, byteCount); // 存入环形缓冲…… // 请求界面重绘(不能在音频线程直接操作控件) this.Invalidate(); }Invalidate() 是线程安全的,它只是给 Windows 消息队列发一个重绘信号,UI 线程会在空闲时执行 OnPaint。这样绘图和音频数据生产就是在两个线程上解耦执行了。
推荐做法二:用 System.Windows.Forms.Timer,定时 30ms 触发一次刷新。
Timer refreshTimer = new Timer(); refreshTimer.Interval = 30; refreshTimer.Tick += (s, e) => this.Invalidate();我个人偏爱方法二,因为它刷新节奏恒定,不依赖声卡回调频率,CPU 占用也更平稳。
另外,WinForms 的默认控件是带背景擦除的,会导致闪烁。记得在控件构造函数里加上双缓冲设置:
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);或者直接重写 OnBackgroundPaint 为空方法。这招能消除 90% 的波形图闪烁问题。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 问题现象 | 原因分析 | 解决方案 |
|---|---|---|
| 录音文件打不开或体积为0 | WaveFileWriter 未正确写入或未 Dispose | 停止录音后调用 writer.Dispose() 等待文件头写入完成 |
| 播放有"咔咔"断音 | BufferedWaveProvider 数据供应不及时或缓冲太小 | 增大 BufferDuration,确保数据持续 AddSamples |
| 播放没有声音 | 播放设备索引不对,或缓冲中没有数据 | 检查 waveOut.DeviceNumber,确认录制回调触发了 AddSamples |
| 波形图闪烁严重 | 未启用双缓冲,或 OnPaint 中耗时操作过多 | 开启 OptimizedDoubleBuffer,数据压缩后再绘制 |
| 波形是满幅直线 | 字节转采样的符号处理错误,无符号数据被当成了有符号 | 确认 BitConverter.ToInt16 使用小端序,且数据为有符号 16bit PCM |
| DataAvailable 回调卡顿 | 回调中做了文件 IO、UI 操作等耗时任务 | 回调内只做数据转发,压缩、绘图放到 UI 线程 |
| 多设备环境录错音源 | DeviceNumber=0 指向默认设备,但实际需要指定麦克风 | 枚举 WaveInEvent.DeviceCount,让用户选择设备索引 |
5.2 我踩过最深的三个坑
第一个坑是播放缓冲和数据格式不匹配。有一次我把 44100Hz 的文件流喂给初始化成 16000Hz 的 BufferedWaveProvider,结果播放出来的声音变成“花栗鼠声”,音调全乱了。原因很简单:BufferProvider 的采样率必须和 AddSamples 进去的数据采样率一致,否则整个播放节奏就是错的。
第二个坑和波形图绘制有关。一开始我直接把所有采样点都画出来,窗口一放大,CPU 直接 100%,界面卡得动不了。后来我意识到:波形画的是“趋势”,不是“细节”。视觉上人眼根本分辨不出一像素里到底是 1 个采样点还是 20 个采样点,所以峰值压缩是必须的,数据量降低 20 倍,画面还更清晰,因为这个处理方式保证了每个像素显示的都是该区间内幅值最大处。
第三个坑比较隐蔽:录音数据从设备到回调本身有延迟,录制 50ms 的 BufferMilliseconds 并不代表屏幕上看到的波形和真实声音是同步的。实测下来,设置了 BufferMilliseconds=50 时,从发声到波形出现大约有 80~120ms 延迟。如果你做的是示波器类的严格测试工具,这个延迟可能是个问题,需要另外做时间戳校准;如果只是做可视化监控,这 100ms 的延迟完全不碍事。
5.3 音频格式兼容性小贴士
最后补充一点:NAudio 的 WaveFileWriter 对 16bit PCM 格式支持最好,这也是我上面所有代码都用 16bit 单声道的原因。如果你要录 MP3 或者 24bit 格式,NAudio 也有对应的编码器,但要做格式转换,代码复杂度和踩坑概率都会上升。我的经验是:除非业务明确要求,否则一律用 16bit PCM WAV,反正后续要转换格式,用第三方工具或 NAudio 的转换库随时可以做。
结语与扩展建议
项目到此已经完整跑通录音、播放、波形实时显示三个核心功能。这段时间做下来,我最大的体会是:音频开发没有想象中的神秘,核心就是处理好“数据从哪里来、到哪里去、怎么展示”这条链路,而 NAudio 把最难的部分都封装好了,剩下的就是耐心打磨缓冲和绘制的细节。
如果你做完这个基础版,想继续往深处玩,我建议按这个顺序扩展:
- 加入音频数据的频谱分析,把波形图升级成动态频谱图(用 NAudio 的 FFT 类,或者找现成的 FFT 库)
- 做多声道采集,把两个麦克风的波形叠加显示,用于声源定位或噪音对比
- 加入人声检测算法,波形有声音时自动开始录音,静音超过一定时长就自动停止,做会议纪要工具很实用
最后再分享一个我在实际项目里验证过的操作细节:把录音文件命名加上时间戳,然后用 DateTime.Now.ToString("yyyyMMdd_HHmmss") 避免重名。这看似不起眼,但当你连续录十几个测试音频时,就会庆幸当时做了这个小设计。
本文还有配套的精品资源,点击获取