C# NAudio 实现录音播放与实时音频波形绘制方案
2026/9/9 1:03:52 网站建设 项目流程

简介:一份基于C#与NAudio实现录音、播放及实时绘制音频波形图的完整示例工程,面向需要处理音频数据并做可视化呈现的.NET桌面开发者。与常见方案不同,示例从音频流中直接获取原始样本而非连接外部设备,完整演示WaveInEvent采集声卡输入、WaveOutEvent输出到扬声器、在WPF中通过PolylineWaveFormControl等自定义控件实时刷新波形,同时覆盖样本归一化、数据缓冲、定时器或事件驱动的UI更新等关键细节,适合作为音频编辑器、实时频谱分析或录音工具的原型参考。压缩包共171个文件,以44个cs源码、xaml/baml界面、30个wav示例音频为主,另附13个dll运行库、config配置、txt说明及sln工程文件,整体仅3.01MB,便于快速下载并直接对照调试。目前已有6474人学习下载,对中高级C#开发者而言,是一份兼顾调用流程与波形绘制实现的务实范本。 在做音频相关的上位机或者工具类软件时,很多人都会碰到这么一类需求:要用C#录音、要能播放音频文件,还得在界面上实时画一条跳动的音频波形图。最麻烦的点往往不是录音和播放本身,而是波形图的数据到底怎么来。我这次把整个实现方案梳理了一遍,核心思路是让波形数据直接从音频流里“顺路拿下”,而不是额外开一路设备采集。这套方案用NAudio实现,实测下来逻辑清晰、同步性好,值得分享给正在做类似功能的朋友。

1. 项目思路:为什么波形数据要“顺路拿”,而不是单独去设备采集

1.1 这条需求背后的典型场景

录音和播放加波形绘制,这组功能最常见的出现位置是上位机软件、语音质检工具、音频调试面板这类项目里。用户操作界面上有一排按钮,开始录音、停止、播放、暂停,旁边一块区域实时显示波形。功能本身不难,难的是做出来之后好不好用。

你想想,如果波形和声音对不上,或者声音已经开始播放了,波形还要等几百毫秒才动,这种体验放在测试工具里尤其致命。做上位机的人应该都遇到过这种尴尬:调了半天,最后发现是数据源选错了。

1.2 “从设备获取”和“从音频流获取”的本质区别

这里要先把概念掰扯清楚。从设备获取,指的是用环路录音的方式,把播放出去的声音用声卡再录回来,或者是用WasapiLoopbackCapture这类接口直接抓系统混音器的输出。这种做法有几个问题:

一是时序对齐困难。播放和抓取是两条链路,延迟不同步,波形落后、超前都可能出现,要反复校准。

二是资源抢占。设备被两路同时占用时,驱动层的调度行为不可控,偶尔会出现爆音。三是逻辑混乱。本来只想显示播放文件的波形,结果把系统里其他应用的声音也抓进来了,界面上出现乱七八糟的波峰,用户一脸懵。

所以,这次方案就定了一个原则:波形图的数据,从播放或录音的音频流内部直接截取。播放时,在SampleProvider链路上做一层拦截,数据从文件读出经过我们手里时,拷贝一份给波形模块,剩下继续给声卡。录音时,WaveInEvent的DataAvailable事件拿到的PCM数据,一部分写文件,一部分抽出来画图。这就保证了波形和声音永远走同一条路、同一个时钟,天然同步。

1.3 整体数据链路设计

整个方案的链路可以这样理解:录音链路是从麦克风到PCM数据,然后分叉两路,一路进WAV文件,一路进波形缓冲区;播放链路是从音频文件到SampleProvider流链,中间我们插入一个自定义的波形提供器,数据经过时被复制一份,再继续流向声卡输出。

绘图层不关心数据是录音来的还是播放来的,只要一个float数组,里面是归一化到-1到1之间的PCM采样值,就能画。这样录音和播放的波形显示模块就可以完全共用一套绘制代码,省事不少。

2. 技术选型与核心类设计

2.1 NAudio版本与三个核心组件的分工

NAudio这个库,用C#做音频处理的基本都绕不开。我用的版本是1.10.x或2.x,功能差异不太大,下面这些API在两个版本里都是稳定的。项目里主要用到三个核心组件,分工很明确:

WaveInEvent负责录音,优点是使用简单、兼容性好,走的是传统waveIn系列API。AudioFileReader负责解码音频文件,可以统一读取WAV、MP3等格式,并且它本身实现了ISampleProvider,能直接输出float类型的PCM数据。WaveOutEvent负责播放,用它把SampleProvider流送到声卡。

这套组合的好处是整个播放链路全程都是ISampleProvider,数据处理起来非常顺手。不用在byte数组和float数组之间来回折腾。录音链路虽然拿到的是byte数组,但只要统一转成float就可以走同一个波形绘制逻辑。

2.2 录音链路:WaveInEvent捕获麦克风数据

WaveInEvent使用起来很简单,设置好WaveFormat,订阅DataAvailable事件,调用StartRecording就开始录音。这里有个关键点:这个事件回调是在后台线程执行的,不是UI线程。千万不要在事件里直接操作PictureBox或者调用Invalidate,否则会有跨线程问题和界面卡顿隐患。

推荐的做法是:事件里计算好波形数据,塞进一个线程安全的缓冲区,界面层用Timer定期去取数据来画。数据比较密集的话,也可以先在事件里做降采样,只把峰值数据存下来,减少UI负担。

录音时还要注意设备选择。如果机器上有多个麦克风,可以在NAudio的WaveInEvent.DeviceCount和GetCapabilities之间遍历,把设备列表填到ComboBox里让用户选。默认设备在某些机器上可能是“立体声混音”,录出来的波形根本不动,这种问题排查起来特别费时,所以把设备选择暴露给用户是很有必要的。

2.3 播放链路:从AudioFileReader到WaveOutEvent的SampleProvider链

播放链路的典型写法类似这样:创建AudioFileReader,然后可以直接交给WaveOutEvent去Init并Play。但为了拿波形数据,我们要在中间插入一层。AudioFileReader本身是ISampleProvider,所以我们写一个自定义类,比如叫WaveformSampleProvider,它接收另一个ISampleProvider作为数据源,在Read方法里先调用内部的Read读取采样数据,把数据复制一份触发波形事件,然后再原样返回。

这样设计后,整个链路变为:AudioFileReader -> WaveformSampleProvider -> WaveOutEvent。波形数据在SampleProvider这一层拦截到,等于做了个旁路监控,不破坏原有数据流。

选择在这个位置拿数据,而不是在播放结束后去读文件再画,最大的价值就是实时性。用户点播放那一刻,数据就同步从文件流向声卡和波形模块,中间没有任何额外等待。暂停、恢复、拖动进度,波形都能第一时间跟随。

3. 核心代码实现:一步步搭建波形绘制

3.1 录音数据提取与波形画面送入

先看录音这一路的实现。我按下面的方式组织代码:

using NAudio.Wave; // 录音相关字段 private WaveInEvent _waveIn; private WaveFileWriter _writer; private ConcurrentQueue<float> _waveSampleQueue = new ConcurrentQueue<float>(); private void StartRecording(string filePath) { _waveIn = new WaveInEvent { WaveFormat = new WaveFormat(44100, 16, 1), BufferMilliseconds = 50 }; _writer = new WaveFileWriter(filePath, _waveIn.WaveFormat); _waveIn.DataAvailable += OnDataAvailable; _waveIn.RecordingStopped += (s, e) => _writer?.Dispose(); _waveIn.StartRecording(); } private void OnDataAvailable(object sender, WaveInEventArgs e) { // e.Buffer 是本次采集到的 PCM 字节,e.BytesRecorded 是实际有效字节数 _writer.Write(e.Buffer, 0, e.BytesRecorded); // 16bit 单声道,每个采样占2字节,按小端转为 short,再归一化到 -1~1 int sampleCount = e.BytesRecorded / 2; for (int i = 0; i < sampleCount; i++) { short sample = BitConverter.ToInt16(e.Buffer, i * 2); float normalized = sample / 32768f; while (_waveSampleQueue.Count > 44100 * 2) // 最多保留2秒数据 { _waveSampleQueue.TryDequeue(out _); } _waveSampleQueue.Enqueue(normalized); } }

这里我用了ConcurrentQueue做缓冲区,同时限制最大长度为2秒。这个长度限制很重要,否则长时间录音时队列无限膨胀,内存占用会持续上涨。2秒的数据量对波形显示来说也足够,滑动显示的效果正好合适。

3.2 从播放音频流中拦截采样数据

播放链路的核心是自定义SampleProvider。实现如下:

public class WaveformSampleProvider : ISampleProvider { private readonly ISampleProvider _source; private readonly ConcurrentQueue<float> _sampleQueue; public WaveformSampleProvider(ISampleProvider source, ConcurrentQueue<float> sampleQueue) { _source = source; _sampleQueue = sampleQueue; } public WaveFormat WaveFormat => _source.WaveFormat; public int Read(float[] buffer, int offset, int count) { int samplesRead = _source.Read(buffer, offset, count); for (int i = 0; i < samplesRead; i++) { while (_sampleQueue.Count > 44100 * 2) { _sampleQueue.TryDequeue(out _); } _sampleQueue.Enqueue(buffer[offset + i]); } return samplesRead; } }

这段代码的巧妙之处在于,我们只是旁路了一份数据,不会修改原buffer的内容,所以对正常播放不会产生任何影响。AudioFileReader读取的文件格式不管是44100还是48000采样率,波形数据的时间跨度计算要按实际采样率来,绘制时注意换算即可。

调用的时候这样组装:

_audioFileReader = new AudioFileReader(filePath); _waveformProvider = new WaveformSampleProvider(_audioFileReader, _waveSampleQueue); _waveOut = new WaveOutEvent(); _waveOut.Init(_waveformProvider); _waveOut.Play();

3.3 波形绘制与GDI+双缓冲

波形绘制我用的是GDI+,没有引入额外图表库。因为波形图逻辑相对简单,GDI+完全够用,而且依赖越少越不容易出问题。

核心思路是“峰值包络”:把屏幕宽度分成若干个像素列,每个像素列对应一段采样数据,取这段数据的最大值和最小值,画一条竖线。这样画出来的效果就是音频编辑器里常见的实心包络波形。

private void DrawWaveform(Graphics g, float[] samples, int width, int height) { g.Clear(Color.FromArgb(30, 30, 30)); if (samples.Length == 0) return; using (var pen = new Pen(Color.LimeGreen, 1f)) { for (int x = 0; x < width; x++) { int start = (int)((long)x * samples.Length / width); int end = (int)((long)(x + 1) * samples.Length / width); if (end <= start) end = start + 1; float min = float.MaxValue; float max = float.MinValue; for (int i = start; i < end && i < samples.Length; i++) { if (samples[i] < min) min = samples[i]; if (samples[i] > max) max = samples[i]; } int yMin = (int)((1 - min) * height / 2); int yMax = (int)((1 - max) * height / 2); g.DrawLine(pen, x, yMin, x, yMax); } } }

画图本身不复杂,但界面表现上有一个坑必须处理:闪烁。直接在控件Paint事件里画,数据量大时会闪得厉害。解决方案是开启双缓冲,或者在内存里先画好再一次性贴到屏幕。我用的是手动双缓冲,代码大概是这样:

private void RenderWaveform() { var buffer = new BufferedGraphicsContext(); using (var graphics = buffer.Allocate(pictureBoxWave.CreateGraphics(), pictureBoxWave.ClientRectangle)) { DrawWaveform(graphics.Graphics, GetSamples(), pictureBoxWave.Width, pictureBoxWave.Height); graphics.Render(pictureBoxWave.CreateGraphics()); } }

刷新节奏我用System.Windows.Forms.Timer,间隔50毫秒一次。这个间隔人眼看起来已经是连续的了,而且CPU占用很低。不要用高频Timer试图做到“绝对实时”,音频数据量大,过高的绘制频率只会让界面卡顿,波形显示效果反而更差。

数据从队列取出来这块,需要注意一个细节:绘图线程每帧只取队列尾部最新的部分数据,而不是从头取到尾部。因为队列里面存放的是最近2秒的数据,如果每帧都全量重画,当数据不断增长时,之前的历史片段会被压缩得越来越密,界面看起来就是一团糊。我推荐的做法是:每帧取最近约20万点数据(大约1秒),固定时间窗口,画出来的滚动波形才稳定清晰。

3.4 录音、播放共用一套绘制模块的衔接

录音和播放进入的是同一个ConcurrentQueue队列,但正常情况下不会同时使用,所以不会串数据。如果项目里需要同时录音和播放并且希望分别显示两条波形,那就各建一个队列、各建一个绘制控件,不要共用,否则界面会同时叠加两路波形的数据,根本看不清楚。

我建议代码组织上把“数据采集”和“波形绘制”分成两个类:音频服务类只负责把采样数据丢进队列,波形控件类只负责从队列读数据显示。这样录音和播放都使用同样的数据通道,替换数据源时不影响绘制逻辑。

4. 实际调试中的坑与排查记录

4.1 波形图锯齿严重或者断裂

这个问题多发生在采样率不匹配或者数据量不足时。比如播放的MP3是22050采样率,波形按44100的时间跨度去计算,看起来就会稀疏甚至断层。解决办法是绘制时根据WaveFormat.SampleRate来换算时间轴。

另外,从我自己的经验来看,绘制时使用每像素列内取min/max的方式,比单纯画折线要平滑得多。折线方式在音频波形上很容易出现密密麻麻的毛刺,峰值包络方式既保留动态范围,视觉上又干净。

4.2 界面卡顿,按钮点击反应迟钝

十有八九是你在DataAvailable或者SampleProvider的Read回调里直接操作了UI。这两个回调都是音频线程,高频调用,任何UI操作在这里都是大忌。正确做法是只做数据入队,所有UI刷新交给Timer。

还有一个隐藏问题:如果ConcurrentQueue没有做长度限制,长时间运行后内存越来越大,GC频繁触发也会拖慢界面。我最初没加队列上限时,录了10分钟就明显感觉界面发涩,加上2秒上限后问题立刻消失。

4.3 录出来的WAV文件时长不对或者文件损坏

这个情况和很多人想的不一样。WaveFileWriter在写入时,文件头的RIFF长度字段是最后Dispose时才回写的。如果程序中途崩溃或者没有释放资源,文件头里显示的时长可能是0或错误值,但实际数据都在文件里。

所以录音停止时,一定要保证WriteDispose被调用。项目里如果用了async/await做停止逻辑,注意Dispose和Stop的顺序。我习惯这样:

_waveIn.StopRecording(); _waveIn.Dispose(); _writer?.Dispose(); _writer = null;

StopRecording之后立刻Dispose writer,把WAV头写完整。如果你想检测文件是否完好,可以用FFmpeg或者Audacity打开验证,这两个工具对音频文件的分析能力很强,播放时长、波形、频谱一眼就能看出问题。

4.4 波形和声音不同步

如果你在播放文件,WaveOutEvent已经开始发声了,但波形界面还没动,说明数据不是来自播放链路,而是你用了另一路采集。确认一下你的WaveformSampleProvider是不是真的挂在WaveOutEvent.Init的参数链上。经常有人代码写了两遍:一遍给播放,一遍给录制,结果播放根本没走自定义Provider,波形当然不动。

另一个容易忽视的点是:WaveOutEvent播放有内部缓冲,大概几十到几百毫秒。波形数据在Read时被截获,实际上比声音从扬声器出来要早一点点,这个提前量人耳和视觉基本无感,不需要做特殊补偿。但如果你把波形数据存储后再回放对比,会发现时间对不齐,这是正常现象,别自己吓自己。

4.5 录音时麦克风录入播放声导致波形叠加

这个属于声学问题不是代码问题。如果你的播放声被麦克风重新采集到,录音波形会包含回声成分,波形看起来峰值特别大,形状杂乱。解决方式有三种:一是戴耳机,物理隔离;二是在NAudio里启用系统自带的回声消除(AEC)效果,但不同声卡驱动支持程度不一致;三是播放时把录音暂停,逻辑上避让。工具类软件我一般推荐第一种方案,简单直接。

5. 从波形数据到更多实用功能的扩展思路

波形图做出来之后,如果只用来显示,其实有点浪费。这套数据链路完全可以继续往外延伸。比如要做录音的“过零检测”,判断有没有人说话,只需要统计队列里一段数据的能量值,高于阈值就认为有声音。要做“峰值表”(类似音量VU表),也只需要抽取最近一帧的max绝对值,画一条柱状条就行。

播放器的进度条联动也能基于这套机制实现。AudioFileReader的CurrentTime属性会随着播放更新,在Timer刷新波形时可以顺便读取并更新进度条位置。这里有个小技巧:拖动进度条后,调用AudioFileReader.CurrentTime = new TimeSpan(...)定位,但要小心,定位后内部缓冲会被清空,波形数据和声卡输出会有一个极短暂的“追帧”过程,这是正常的,一般几十毫秒内会自行恢复。

另外,如果你要把波形导出成图片,比如生成音频预览图,思路完全相同:把整段音频文件用AudioFileReader打开,循环Read到文件末尾,边读边做峰值压缩,最后把整条峰值数组画到Bitmap上保存。这个功能对很多业务场景都很有用,生成的缩略图直接放到文件列表里做视觉索引,比列表文字直观得多。

6. 最后分享一个小技巧

我在做波形显示时踩过几次坑,最后养成一个习惯:所有从音频流里提取出来的float采样点,先做一次“防止越界”的钳制。原因很简单——某些音频文件本身可能包含超出[-1, 1]范围的非法采样值,特别是在一些非标准编码的文件里。位图绘制时如果sample大于1或者小于-1,坐标会画到控件外面,轻则波形显示异常,重则GDI+直接抛异常。加一行Math.Clamp(normalized, -1f, 1f),就能提前拦截住这一类数据异常。

这个项目做下来,最核心的体会就是:波形图只是表象,数据从哪里来、从哪条链路取,才是真正的技术决策点。从音频流顺路拿数据这条思路,解决的不只是代码量,更重要的是从根本上保证了波形和声音的同步性。如果你正在做类似功能,建议直接从这套方案上改,少走弯路。

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

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

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

立即咨询