C#上位机音频开发:NAudio实现录音播放与实时波形绘制
2026/9/3 21:22:47 网站建设 项目流程

简介:面向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); } } }

这里有三个细节:

  1. 除以 32768 是因为 16bit 有符号数的最大值是 32767,这样能把幅值归一化到画布高度范围。
  2. 绘制时用折线比用垂直线段好看,也更接近传统示波器效果。
  3. 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 高频问题速查表

问题现象原因分析解决方案
录音文件打不开或体积为0WaveFileWriter 未正确写入或未 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") 避免重名。这看似不起眼,但当你连续录十几个测试音频时,就会庆幸当时做了这个小设计。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询