简介:NAudio是C#生态中常用的开源音频处理库,这套资源以'NAudio-master'命名,完整收录了库源码及配套示例,面向需要实现录音、播放、格式转换或混音功能的.NET开发者。压缩包共873个文件、约2.99MB,以C#源文件(cs)为主,辅以Markdown说明文档、项目配置文件、XAML界面资源、WAV示例音频等,目录结构完整,便于检索与二次开发。库本身支持WaveIn/MME等接口进行录音,WaveOut或ASIO设备提供低延迟播放,可处理常见音频格式转换,并通过MixingSampleProvider实现多路混音。包内含Visual Studio解决方案、构建脚本及可直接运行的示例工程,适合C#初学者学习音频处理原理,也适合进阶开发者将源码集成到实际项目中。目前已有417人学习下载,对于希望深入理解音频采集、回放与格式处理细节的开发者,是一份实用的参考素材。
1. 用 C# 录音,NAudio 是唯一一个让你绕过音频底层细节的开源库
很多 C# 开发者第一次接触录音需求,都是从「上位机要加一个语音采集功能」或者「WPF 程序里要录一段麦克风」开始的。调用系统 API 实在繁琐:要处理 waveIn 回调、缓冲区指针、RIFF 头,还要小心.NET 的委托被 GC 回收。而 NAudio 作为一款老牌 C# 开源音频库,把录音链路封装成三个核心对象:设备枚举、波形格式、数据写入器。你只需要告诉它用哪个麦克风、按什么格式采、数据来了放哪,剩下的搬字节、对缓冲、写 WAV 头都交给库去处理。
本文围绕「录音」这一个动作展开:先讲清 NAudio 录制链路的选型,再给出可直接运行的最小代码,然后拆解采样率、位深、声道数和缓冲参数,接着介绍保存为 WAV 以及转成 MP3/AAC 的常见方案,最后落到设备占用、音质发闷、爆音等实战排错。如果你之前只是把 NAudio 当成「能出声的库」,这篇会帮你把录音这条路走通到可以上生产。
2. 从设备列表到最小可录音代码:NAudio 录音链路的核心对象
2.1 为什么先枚举设备而不是直接用 0 号设备
录音的第一步不是创建录制器,而是确认你要从哪个输入设备采集。台式机常有「立体声混音」「麦克风阵列」「USB 声卡」多个输入,直接写死设备序号 0 很容易录到空数据。NAudio 的WaveInEvent.DeviceCount静态属性可以拿到输入设备数量,再用WaveInEvent.GetCapabilities(deviceIndex)获取设备名和声道支持情况。
for (int i = 0; i < WaveInEvent.DeviceCount; i++) { var caps = WaveInEvent.GetCapabilities(i); Console.WriteLine($"设备 {i}: {caps.ProductName}, 声道: {caps.Channels}"); }这段代码在应用启动时执行一次,把设备名和索引显示在界面上让用户选择。Channels表示该设备的最大输入声道数,多数麦克风返回 1 或 2。注意这里拿到的是系统当前可用设备,某些设备被其他进程独占时仍然会出现在列表里,真正的占用检查要等到StartRecording()时才会暴露。所以枚举设备只是第一步,实际录制前还要做异常捕获。
2.2 用 WaveInEvent 跑通第一个 10 秒录音的最小代码
WaveInEvent是 NAudio 中基于传统 waveIn API 的录制类,延迟略高但兼容性最好,适合 WinForms、WPF、上位机这类不苛求实时性的场景。一个最小可录音的代码块只需四步:创建波形格式、订阅DataAvailable和RecordingStopped事件、调用StartRecording()、停止后把累积数据写盘。
using NAudio.Wave; var outputPath = @"D:\recordings\test.wav"; var mediaFoundationWriter = new MediaFoundationWriter("placeholder"); // 仅示意,稍后替换 // 实际用 WaveFileWriter 更方便,直接写 WAV var writer = new WaveFileWriter(outputPath, new WaveFormat(44100, 16, 1)); var waveIn = new WaveInEvent { DeviceNumber = 0, WaveFormat = writer.WaveFormat, BufferMilliseconds = 50, NumberOfBuffers = 3 }; waveIn.DataAvailable += (s, a) => { writer.Write(a.Buffer, 0, a.BytesRecorded); }; waveIn.RecordingStopped += (s, a) => { writer.Dispose(); }; waveIn.StartRecording(); System.Threading.Thread.Sleep(10000); // 模拟录音 10 秒 waveIn.StopRecording();代码逻辑很直白:WaveFileWriter在创建时会把 WAV 头预留好,DataAvailable每次收到一块 PCM 字节就把它们追加进文件。BufferMilliseconds决定每次回调的粒度,50 毫秒是比较中庸的折中,既不频繁触发回调,也不会在界面卡顿时丢失太多数据。NumberOfBuffers告诉 waveIn 准备几个内部缓冲轮流填充,3 个足以应对一般负载。
2.3 DataAvailable 里的字节缓冲该怎么按波形对齐
DataAvailable事件参数WaveInEventArgs中的Buffer不是数组一次性攒完的,它是一块固定大小的字节切片。要理解它的长度,得先算一秒钟的字节数:采样率 × 位深/8 × 声道数。比如 44100Hz、16bit、单声道,每秒就是 44100 × 2 × 1 = 88200 字节。BufferMilliseconds = 50时每次回调大约 4410 字节。
实际操作里不能用a.Buffer.Length,要使用a.BytesRecorded,因为最后一块缓冲可能不满。另外,如果你要做实时频谱或电平检测,需要把字节按样本对齐。16bit 的样本是有符号短整型,小端序,转换方法如下:
short sample = BitConverter.ToInt16(a.Buffer, offset);这里的offset每次按 2 字节递增,直到offset < a.BytesRecorded - 1。如果你设置的是 24bit 格式,就得手动拼三个字节并用(sample << 8) >> 8转成有符号数。这个对齐细节看似基础,但网上很多「录出来全是爆音」的提问,根源就是把 16bit 数据当 8bit 读,或者把单声道按双声道交错解析。
3. 录音参数怎么设:采样率、位深、声道数不是随手填的
3.1 WAV 格式约定与 WaveFormat.CreateIeeeFloat 的取舍
WaveFormat构造函数有多个重载,最常用的是new WaveFormat(sampleRate, bitsPerSample, channels)。44100Hz、16bit、单声道是 CD 标准,也是 Windows 麦克风默认输入的主流格式。但 16bit 并不是唯一选择,NAudio 提供WaveFormat.CreateIeeeFloat(44100, 1)创建 32 位浮点采样格式,这在处理增益和混音时尤其方便:浮点样本范围是 -1.0 到 1.0,做乘法衰减不会像定点整数那样产生截断失真。
但要注意,WaveInEvent底层走的是 waveIn API,很多声卡驱动并不原生支持 32 位浮点格式。虽然 NAudio 会尝试自动转换,但在某些老驱动上会直接抛MmException。以笔者的经验,追求兼容性就用 16bit,追求后期处理精度就先用 16bit 录制,进入信号链后再用WaveFormatConversionStream转成 IEEE 浮点。这也是为什么录音模块的参数界面里,「采样率 44100」「位深 16」「单声道」这三个默认值能应付 90% 的场景。
3.2 三个必调参数:BufferMilliseconds、NumberOfBuffers、DesiredLatency
WaveInEvent的三个参数直接决定录音的流畅度和 CPU 占用,很多「录音卡顿」「开始录音时界面假死」的问题都出在这组参数上。
| 参数 | 含义 | 建议值 | 调整影响 |
|---|---|---|---|
BufferMilliseconds | 每个缓冲容纳的毫秒数 | 20~100 | 值越小回调越频繁,延迟越低,CPU 越高 |
NumberOfBuffers | 排队缓冲数量 | 2~5 | 值越大抗抖动越强,内存占用越高 |
DesiredLatency | 预期延迟(部分录制器) | 100~200ms | 低于驱动能力时会出现断流 |
三条经验:上位机界面需要实时刷新电平表时,BufferMilliseconds取 20~30,配合Timer每 100ms 读一次音量。纯后台录音不关心界面实时性,取 100 更稳,回调次数少,磁盘写的压力也小。NumberOfBuffers建议固定 3,低于 2 时 Windows 的音频栈一旦调度抖动就会丢缓冲,高于 5 对多数场景没有额外收益。
3.3 音量、静音检测与自动停止的阈值经验值
录音软件通常需要「检测到声音才开始录、静音超过 N 秒自动停止」。NAudio 没有内置 VAD,但我们可以用WaveInEvent的DataAvailable自己算 RMS。RMS 即均方根,能代表一段信号的能量。计算时需要把字节转成样本,再求平方和均值再开方。下面是 16bit 单声道的实现:
private float ComputeRms(byte[] buffer, int bytesRecorded) { float sum = 0; int count = bytesRecorded / 2; for (int i = 0; i < bytesRecorded - 1; i += 2) { short sample = BitConverter.ToInt16(buffer, i); sum += sample * sample; } return (float)Math.Sqrt(sum / Math.Max(count, 1)); }RMS 的绝对阈值受麦克风增益影响很大。笔者通常的做法是:先让用户对着麦克风正常说话 3 秒,取这段时间 RMS 的 30% 作为触发阈值。静音判断则用「连续 1.5 秒 RMS 低于阈值的 60%」作为停止条件。这里不要用绝对值 0,因为声卡底噪会让完全无声时 RMS 也在 300~800 之间。用一个固定经验值:16bit 下,普通会议室背景噪声 RMS 约 500~1500,说话时约 5000~20000,所以初始阈值设为 3000 是合理的起步值。
4. 保存与转码:从 WAV 到 MP3/AAC 的进阶处理
4.1 用 WaveFileWriter 落盘,注意 RIFF 头和数据块对齐
WaveFileWriter构造时会自动写入 RIFF 头和fmt块,数据块大小字段在Dispose()时才回填。因此录音过程中文件头是不完整的,如果中途断电或进程崩溃,这个 WAV 文件就无法被播放器识别。这也是为什么建议录音过程中不要直接写最终文件,而是先写临时文件,停止后再用WaveFileWriter.SafeRecordToWaveFile或手动复制。
还有一个隐蔽坑:某些第三方播放器对 WAV 数据块长度要求为偶数,而 16bit 单声道一帧就是 2 字节,天然对齐;但如果你用 8bit 或 24bit,DataAvailable最后一块数据可能不足一帧,需要程序自己丢弃残余字节。NAudio 的WaveFileWriter.Write内部不会帮你做对齐,所以录制 24bit 时建议额外做缓冲拼接。
4.2 转 MP3:MediaFoundationEncoder 与 LAME 编码器怎么选
录完的 WAV 体积太大,一分钟 44100Hz 16bit 单声道约 5.3MB。要转 MP3,NAudio 2.x 提供了两种路径:MediaFoundationEncoder.EncodeToMp3和调用外部 LAME 编码器。
using (var reader = new AudioFileReader(@"D:\recordings\test.wav")) { MediaFoundationEncoder.EncodeToMp3(reader, @"D:\recordings\test.mp3", 128000); }这段代码清晰直观,但有两个前提:系统必须安装了 Microsoft Media Foundation 的 MP3 编码器(Win8+ 自带),且输出比特率只能是编码器支持的档位,比如 128kbps 常用,192kbps 也常见,但 320kbps 在某些精简版 Windows 上不受支持。如果需要精确控制 CBR/VBR 或编码器质量,更稳的做法是使用 NAudio 调用 lame.exe:
var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = @"D:\tools\lame.exe", Arguments = $"--quiet -b 128 \"{wavPath}\" \"{mp3Path}\"", UseShellExecute = false, CreateNoWindow = true }); process.WaitForExit();LAME 的优势是跨平台、参数全、可独立分发,劣势是要额外打包一个 exe。如果目标机器是 Windows 10/11,优先用 MediaFoundationEncoder,省去外部依赖;如果软件要跑在 Windows Server Core 或无媒体特性版本的容器里,就退回 LAME。
4.3 边录边转还是录完再转:两种方式的资源占用对比
实时转码省磁盘空间,但会抢占录音线程的时间片,造成DataAvailable回调延迟。实测在低端工控机上,边录边转 128kbps MP3 会让回调间隔从 50ms 抖到 120ms,偶尔丢缓冲。推荐的做法是:录音时只写 WAV,停止后异步转码。录音文件在内存或临时目录里占据的空间,通过「转码成功后删除临时 WAV」来释放。这个流程对多数 C# 上位机场景已经足够,只有在长时间无人值守录音、磁盘极小时才值得做实时分段转码。
如果你确实要边录边转,可以这样做:每收到一块数据,除了写入WaveFileWriter,同时推入一个并发队列,由另一个线程消费并喂给编码器。这样录音线程不会被编码阻塞,编码线程跟不上时可以丢帧而不会影响已录部分。但要注意编码器的输入必须在正确的采样率上,如果声卡是 48000Hz,转 MP3 前先做重采样,否则输出文件音调会变快或变慢。
5. 实战排错:录音发闷、爆音、设备占用时的三张排查表
5.1 采样率和位深不匹配导致的变调问题
症状:录音文件能播放,但声音明显偏尖、偏快,或者播放器直接报错。根因几乎都是重采样环节写错了参数。比如你用new WaveFormat(48000, 16, 1)录音,却用new AudioFileReader期望 44100Hz,或者手动拼接 WAV 头时写的采样率字段与实际数据不符。NAudio 提供了现成的重采样器WdlResamplingSampleProvider,它只接受ISampleProvider,所以先用Pcm16BitToSampleProvider包装,再重采样到 44100。
var pcm = new RawSourceWaveStream(File.OpenRead("input.pcm"), new WaveFormat(48000, 16, 1)); var sampleProvider = new Pcm16BitToSampleProvider(pcm); var resampled = new WdlResamplingSampleProvider(sampleProvider, 44100);注意RawSourceWaveStream读的是纯 PCM 裸数据,不含 WAV 头。如果你拿到的文件已经有 RIFF 头,直接使用AudioFileReader并设置OutputSampleRate即可。排查时先用ffprobe或 Audacity 查看文件头里的实际采样率,再对比声卡设置,多数问题一眼就能看出来。
5.2 设备被占用与独占模式:ShareMode 需要显式声明
调用StartRecording()时抛出MmException (错误码 2: 指定的设备可能已打开),这是最经典的设备占用错误。其他程序(如微信、浏览器会议)可能以独占模式打开了麦克风。WaveInEvent默认共享模式,但某些驱动只支持独占。可以尝试将WaveInEvent替换为WasapiCapture并指定AudioClientShareMode.Shared:
var capture = new WasapiCapture( MMDeviceEnumerator.EnumerateDevices(DataFlow.Capture, DeviceState.Active).First(), true, 100);第一个参数是设备对象,第二个true表示共享模式,第三个 100 毫秒是期望缓冲延迟。WasapiCapture对输入格式的要求比WaveInEvent更严格,建议配合AudioFileReader处理格式转换。如果共享模式仍失败,检查 Windows 隐私设置里的「麦克风」是否允许应用访问,这一项在自动更新后经常被重置。
5.3 用 AudioFileReader 验证录音文件的可读性
录音停止后,不要急着转码,先用AudioFileReader把文件读一遍,既能验证文件完整性,还能拿到实际格式:
using (var reader = new AudioFileReader(tmpWavPath)) { var testBuf = new byte[reader.Length]; int read = reader.Read(testBuf, 0, testBuf.Length); if (read < reader.WaveFormat.AverageBytesPerSecond) Console.WriteLine("文件过短或读取异常"); }这里读取长度如果小于 1 秒的数据量,说明文件头或数据块可能损坏。还可以用reader.TotalTime与预期录音时长对比,误差应在 100ms 内。这个验证步骤放在录音模块的单元测试里,每次开发改动后自动跑,能避免很多隐蔽回归。
最后留一个技巧:如果集成了 NAudio 的录音功能,界面上的停止按钮不要直接调用StopRecording()后立即销毁WaveInEvent。正确的顺序是:先调用StopRecording(),在RecordingStopped事件里Dispose()写入器,再用一个Task.Delay(50)或Application.Idle确保底层 waveIn 线程完全退出。直接连续操作有时会让最后一个DataAvailable在对象释放后还会触发,抛ObjectDisposedException,让录音模块显得极不稳定。这个 50ms 的让步,是很多所谓「录音偶发崩溃」的真正解药。
本文还有配套的精品资源,点击获取