☰
WPF 录音播放实战:从 WaveInEvent 到 NAudio 的音频采集与播放方案
2026/10/8 2:17:18 网站建设 项目流程

简介:面向WPF开发者的音频处理示例工程包,基于.NET Framework 4.5与Visual Studio 2017实现录音和播放功能。工程核心采用NAudio库的WasapiLoopbackCapture捕获声卡数据,并借助System.Windows.Media.MediaPlayer完成音频播放,包含从初始化录音、保存WAV文件到暂停、停止控制的完整代码流程,同时提供音频时长、比特率等基础分析示例,适合需要快速集成音频能力的聊天、教育类桌面应用开发人员参考。压缩包共175个文件,约3.18MB,其中45个C#源码文件承担核心逻辑,9个XAML界面文件及对应baml编译资源用于构建UI,13个DLL依赖库支撑运行,另有5个WAV测试音频和多个CONFIG/XML配置,可直接打开Sln工程运行或修改。目前已有926人下载学习,工程内含可运行的WPF演示程序,界面清晰地展示了录音、暂停、停止、播放等按钮与事件绑定的交互方式,并附带NAudio库引用说明与项目文件结构梳理,有助于理解底层音频捕获流程与MediaPlayer播放控制,开发者可在此基础上快速扩展录音格式选择、音量调节等属性与界面。

1. 用 WPF 做录音和播放音频:为什么录音比播放难三倍

接到「做一个 WPF 录音和播放工具」这种需求时,大多数人的第一反应是:不就是点个按钮开始、再点一下停止,然后按播放听一遍吗?实际动手你会发现,播放音频在 WPF 里还算有路可走,录音却几乎找不到官方 API。WPF 没有内置的麦克风捕获类,MediaElement只管播放不管采集,很多初学者卡在「录音按钮按下去毫无反应」这一步就放弃了。

这篇文章是我基于一份可运行的 WPF 录音播放工程拆出来的实操笔记,覆盖了从设备枚举、波形数据回调、wav 文件写出到多格式播放的完整链路。适合两类人:一类是刚入门 WPF、想给应用加录音功能的新手;另一类是已经在用MediaElement或SoundPlayer做播放、但被录音和音频格式问题卡住的老手。你会看到每一步的参数取值、踩过的坑和排查思路,照着敲就能跑通。

2. 先看技术路线:SoundPlayer、MediaPlayer、NAudio 各自管哪一段

2.1 WPF 为什么没有一个内置的麦克风 API

WPF 本身是一个 UI 框架,它的职责是渲染界面和处理输入事件,音频采集不在设计范围内。很多从 Android 转过来的开发者会找类似SoundPool的类,但 WPF 里确实没有等价物;也有人试过用MediaCapture,那是 UWP 的 API,桌面 WPF 工程引用起来非常别扭,而且权限模型和桌面应用不匹配。

所以桌面 WPF 做录音绕不开第三方库。社区里最常用的是 NAudio,它把 Windows 底层的 waveIn 系列 API 封装成了托管类,你不需要写 P/Invoke 就能枚举声卡、开录音设备、拿音频数据。播放方面则有三条路:SoundPlayer适合播放 wav,MediaPlayer(注意是System.Windows.Media下的那个)适合播放 mp3,NAudio 的WaveOutEvent可以用来做低延迟输出。搞清楚这三者各自擅长什么,后面才不会在错误的地方死磕。

2.2 SoundPlayer、MediaPlayer、NAudio 的职责边界

我先说结论,你用的时候也按这个原则选型:

组件支持格式适合场景局限
SoundPlayerwav播放短录音、提示音不支持 mp3,播放时有阻塞风险
MediaPlayermp3、wav、wma播放本地音频文件需要MediaEnded事件手动处理结束状态
NAudioWaveOutEvent任意 PCM 数据实时播放、音频处理需要自己管理数据源和缓冲

如果你只是「录一段 wav,然后播出来」,SoundPlayer加WaveInEvent是最短的路径。如果你还要播放 mp3 并显示播放进度,MediaPlayer更合适。NAudio 的播放通道更适合做变调、混音这类处理,普通场景用不上,但录音非得用它不可。

2.3 工程依赖与目录结构

在实际动手前,先把依赖装上。我用的方案是:录音走 NAudio,播放按文件类型分流。这样做的好处是代码职责单一,录音逻辑和播放逻辑互不干扰。

<PackageReference Include="NAudio" Version="2.2.1" />

依赖装好后,工程里建议按下面的结构组织文件。这个结构不是必须的,但录音、播放、UI 分开之后,排查问题会快很多。

WpfAudioDemo/ ├─ MainWindow.xaml ├─ MainWindow.xaml.cs ├─ Audio/ │ ├─ RecorderService.cs │ └─ PlayerService.cs └─ Helpers/ └─ AudioFormatHelper.cs

RecorderService负责开设备、收数据、写文件;PlayerService负责根据文件后缀决定用哪种方式播放;AudioFormatHelper存放 wav 头部处理和采样率转换的工具方法。后面的章节我都按这个文件职责来讲,你在自己的工程里也可以照这个边界拆分。配套的完整工程示例已经包含在资源包中,里面可以直接运行的MainWindow和各服务的实现,建议对照着看,比纯抄代码更不容易漏掉细节。

3. 录音模块落地:用 WaveInEvent 抓麦克风数据并保存 wav

3.1 初始化录音设备:设备号与 WaveFormat 的选法

录音的第一步是确定用哪个输入设备。大多数机器只有一个麦克风,但双声卡机器(比如有内置麦克风又有 USB 麦克风)枚举出来会有多个设备,写死设备号是个大坑。我一般会在启动录音前先做一次设备枚举,把数量和一两个可用设备名显示出来,方便确认。

public int GetDefaultInputDevice() { int deviceCount = WaveInEvent.DeviceCount; if (deviceCount == 0) { throw new InvalidOperationException("未找到任何录音设备"); } // 枚举设备名,排查时非常有用 for (int i = 0; i < deviceCount; i++) { var caps = WaveInEvent.GetCapabilities(i); Debug.WriteLine($"设备 {i}: {caps.ProductName}"); } return 0; // 默认选第一个设备,实际项目中可做成 ComboBox 让用户选 }

这里有两个点需要说明。WaveInEvent.DeviceCount返回的是系统当前可用的录音设备数量,如果返回 0,多半是系统麦克风权限被关或者驱动没装好,继续往下走只会得到空数据。GetCapabilities(i).ProductName拿到的设备名是 Windows 音频设备显示名,可以用来验证「当前用的是不是我以为的那个麦克风」,接错设备时这一步能救你一命。

3.2 WaveFormat 采样率与位深怎么定

录音参数的核心是WaveFormat。这里我推荐一个通用组合:44100Hz、16bit、单声道。44100 是 CD 音质标准,16bit 是 Windows 音频的主流位深,单声道比双声道数据量小一半,对语音录音完全够用,还省后续处理开销。

private WaveInEvent waveIn; private WaveFileWriter writer; public void StartRecording(string filePath) { waveIn = new WaveInEvent { DeviceNumber = 0, WaveFormat = new WaveFormat(44100, 16, 1), // 采样率44100,位深16,单声道 BufferMilliseconds = 50 // 每50毫秒回调一次数据 }; waveIn.DataAvailable += OnDataAvailable; waveIn.RecordingStopped += OnRecordingStopped; writer = new WaveFileWriter(filePath, waveIn.WaveFormat); waveIn.StartRecording(); }

BufferMilliseconds是关键参数,它决定DataAvailable事件多久触发一次。值越小,回调越频繁,实时性越好,但 CPU 占用越高,极端情况下会出现爆音;值太大,录音延迟明显。50 毫秒是我试过几轮之后觉得最稳的值,语音场景完全够用,音视频同步要求高的场景你可以压到 20。WaveFormat(44100, 16, 1)的三参数分别是采样率、位深、声道数,这三个值必须和后面写 wav 头部的字段对应上,否则录出来的文件会因为波形数据解析错乱而完全没法听。

3.3 数据回调:把缓冲区写进文件而不是 List

DataAvailable事件回调里拿到的是裸 PCM 数据,你只有两个选择:要么立刻写盘,要么累积到内存里等录音结束一并处理。新手最常见的翻车操作是把e.Buffer往List<byte>里一塞,等停止后再写文件——小段录音没问题,一旦录到几分钟,内存直接吃掉几百 MB,UI 卡死一点都不意外。

正确做法是用WaveFileWriter边录边写,它内部维护了正确增长的 wav 头部,你不用自己操心文件长度字段。

private void OnDataAvailable(object? sender, WaveInEventArgs e) { // e.Buffer 是当前缓冲区的 PCM 数据 // e.BytesRecorded 是实际写入的字节数,通常等于 Buffer.Length writer?.Write(e.Buffer, 0, e.BytesRecorded); }

WaveFileWriter在构造时会把WaveFormat信息写入 wav 头部预留区域,之后每次Write都追加波形数据。e.BytesRecorded并不是每次都等于缓冲区大小,少数设备驱动会返回不完整的片段,所以写文件时必须以这个值为准,而不是直接e.Buffer.Length。这段代码本身看着简单,但它是整个录音性能的关键——如果这里再套一层队列或者异步 Task,反而会引入线程安全问题,得不偿失。

3.4 停止录音的时序:先关写入再释放设备

录音停止的逻辑比开始更容易翻车。很多人直接waveIn.StopRecording(),然后马上打开文件,结果发现文件是坏的。原因是StopRecording()只是告诉驱动停止采集,最后的回调数据可能还没处理完,writer 也没刷新到磁盘。

public void StopRecording() { waveIn?.StopRecording(); // RecordingStopped 事件里做真正的清理 } private void OnRecordingStopped(object? sender, StoppedEventArgs e) { // 确保所有缓冲数据写入文件 writer?.Flush(); // 关闭 writer,这会修正 wav 头部的文件长度字段 writer?.Dispose(); writer = null; waveIn?.Dispose(); waveIn = null; }

这个顺序是硬性的:先StopRecording,等RecordingStopped回调触发后Flush,再Disposewriter,最后释放 waveIn。Flush是把托管缓冲推到文件,Dispose才是最终修正 wav 头部长度。如果你反过来,先 Dispose writer 再处理数据,录出来的文件大概率头尾缺失。

如果你的需求是「点停止后立刻拿到文件路径去播放」,注意RecordingStopped是异步回调,别在按钮事件里直接跟着播放,否则文件可能还没写完。我习惯在回调末尾通过事件或 Action 通知 UI 层「录音已保存完整」,再由 UI 决定是否播放。

4. 播放模块落地:把录音文件流畅地放出来

4.1 用 SoundPlayer 播 wav:轻量但不支持 mp3

录完的 wav 文件直接播放用SoundPlayer最省事。它内部走的是 Windows 音频 API,代码量最少,也不需要在界面上放可见控件。

public void PlayWav(string filePath) { using var player = new SoundPlayer(filePath); player.Play(); // 非阻塞播放,不会卡 UI 线程 }

注意Play()是异步播放,PlaySync()才会阻塞调用线程。如果你在按钮事件里用PlaySync(),界面会冻结到播完为止。SoundPlayer支持的文件只有 wav,给它传 mp3 路径会抛FileFormatException,所以用之前必须判断文件后缀。另外它没有「播放进度」和「播放完成」这种可靠的回调,如果你需要状态管理,就得换下面的MediaPlayer。

4.2 用 MediaPlayer 播 mp3:必须处理 MediaEnded 事件

mp3 不能走SoundPlayer,WPF 里对应的是System.Windows.Media.MediaPlayer。它和MediaElement的底层一样,但不需要 UI 控件,适合在 ViewModel 或 Service 层使用。

private MediaPlayer? mediaPlayer; public void PlayMp3(string filePath) { mediaPlayer?.Close(); mediaPlayer = new MediaPlayer(); mediaPlayer.MediaEnded += OnMediaEnded; mediaPlayer.Open(new Uri(filePath, UriKind.Absolute)); mediaPlayer.Play(); } private void OnMediaEnded(object? sender, EventArgs e) { // 回到 UI 线程更新状态 Application.Current?.Dispatcher.Invoke(() => { // 把按钮从「停止」切回「播放」 IsPlaying = false; }); }

MediaPlayer的特点是状态变化全靠事件,它不像SoundPlayer那样有同步的PlayState可查询。MediaEnded在播放自然结束时触发,但如果你中途调用了Close(),它不会触发这个事件。所以你必须在Close()前手动重置 UI 状态,否则会出现「上一首还在显示播放中」的假象。MediaPlayer内部是异步缓冲,Open之后立刻Play()是安全的,不需要等待事件。

4.3 按文件后缀自动选择播放方式

把两个播放能力组合成一个统一入口,调用方就不需要关心文件格式。我这里用后缀判断,注意必须按实际字节检查更严谨,但常规场景下后缀判断够用了。

public void Play(string filePath) { string ext = Path.GetExtension(filePath).ToLowerInvariant(); switch (ext) { case ".wav": PlayWav(filePath); break; case ".mp3": case ".wma": PlayMp3(filePath); break; default: throw new NotSupportedException($"不支持的音频格式: {ext}"); } }

这个分流逻辑是我在实际项目里用的模式,完了你可以在MainWindow里加一个「录音完成后自动播放」的开关,本质就是把RecorderService.RecordingStopped事件里传来的文件路径直接灌进Play方法。链路打通后,整个应用的录音播放闭环就完整了。

4.4 播放器与 UI 线程的边界

凡是涉及SoundPlayer或MediaPlayer的调用,我建议统一在 UI 线程上执行。原因有两个:一是MediaPlayer是依赖 Dispatcher 的组件,跨线程操作可能不触发事件;二是播放状态要写回 UI,切换线程会引入InvalidOperationException。

如果需要在一个后台任务里播放音频(比如语音识别结果回调),可以这样回主线程:

Application.Current.Dispatcher.Invoke(() => { playerService.Play(filePath); });

每个 WPF 应用都有唯一一个Application.Current.Dispatcher,这个调用在大多数场景下不会造成死锁,因为播放操作本身是异步的。唯一需要小心的是不要在 UI 线程里用.Invoke等待一个也在 UI 线程运行的任务,那才会真正卡死。

5. 避坑与排查:录音播放最常见的 5 个问题

5.1 录出来的 wav 打不开:文件头损坏

现象:录音停止后,用播放器打开 wav 文件直接报错,文件大小异常小。

原因:WaveFileWriter没有正常Dispose,wav 头部是空的或者长度字段没更新。常见于停止录音时只调了StopRecording(),没有在RecordingStopped回调里关 writer。

解决:严格按照录音停止的时序来,先Flush再Dispose。另外排查一下OnRecordingStopped是不是被触发后立即执行了StopRecording的后续逻辑,我曾经在这里因为抛异常跳过了Dispose而丢过一整段录音。

5.2 点击录音按钮没反应,程序也不报错

现象:按钮有视觉反馈,但波形没动静,保存的文件里全是静音或大小为 0。

原因:系统麦克风隐私权限没打开。Windows 10 以上版本默认对应用禁用麦克风,WPF 程序不会弹权限确认框,API 调用不报错但拿不到数据。

解决:先到系统设置的「隐私 > 麦克风」里允许桌面应用访问麦克风。更稳妥的方案是在程序入口检测设备数量,WaveInEvent.DeviceCount == 0时直接提示用户检查权限,不要等到录音结束才发现问题。

5.3 录音回放有爆音或声音断断续续

现象:录音文件能播,但中间有轻微的爆破声,尤其在开启聊天的时候更频繁。

原因:BufferMilliseconds设置得太小,导致回调频繁且部分缓冲区数据丢失;或者麦克风设备本身的采样率与构造的WaveFormat不一致。

解决:把BufferMilliseconds调到 50 以上,保证回调速率稳定;录音格式固定用 44100、16bit。如果还不行,把DeviceNumber换一个设备试试——我遇到过同一台机器的 USB 麦克风和内置麦克风对格式的敏感度完全不同。

5.4 MediaPlayer 播放 mp3 静音,但 PotPlayer 能播

现象:MediaPlayer打开文件不报错,进度在走,就是没声音。用 PotPlayer、系统播放器都能正常发声。

原因:WPF 的MediaPlayer对部分编码格式支持不全,尤其是高码率或特殊编码的 mp3。你搜到过「PotPlayer 提示需要 truehd」这类问题,本质上也是系统解码器缺失的连锁反应。

解决:先用 NAudio 的Mp3FileReader读取数据再用WaveOutEvent播放,它不依赖系统解码器。实在不想引入新依赖,可以先把 mp3 转成 wav 再走SoundPlayer,但要注意转换后的体积会膨胀好几倍。

5.5 系统托盘显示「电脑播放音频有个红叉」

现象:程序运行正常,但系统右下角音量图标有红叉,播放任何音频都没声音。

原因:这不是代码问题,是 Windows Audio 服务异常或声卡驱动崩了。常见于休眠唤醒、驱动更新之后。

解决:检查services.msc里的 Windows Audio 服务是否为运行状态,重启服务;再不行就在设备管理器里禁用再启用声卡。这个坑我碰到过两次,每次排查到最后都是系统层面的事,和 WPF 代码无关,但它确实会让人误以为是自己播放器代码写错了。

6. 从能用走向好用:给录音加电平监视,复用数据而不是只存文件

录音功能跑通只是第一步,产品经理很快会提出「你让我看看录音时有没有声音进麦克风」。这个需求本质上是对音频数据的实时分析,和保存文件是两条独立的支线。在DataAvailable回调里,你已经拿到了原始 PCM 数据,完全可以顺手计算音量电平。

private void OnDataAvailableWithMeter(object? sender, WaveInEventArgs e) { // 写入文件(原逻辑) writer?.Write(e.Buffer, 0, e.BytesRecorded); // 新增:计算 RMS 电平 int sampleCount = e.BytesRecorded / 2; // 16bit = 2 字节一个采样点 float sumSquares = 0; for (int i = 0; i < sampleCount; i++) { short sample = BitConverter.ToInt16(e.Buffer, i * 2); sumSquares += sample * sample; } double rms = Math.Sqrt(sumSquares / Math.Max(1, sampleCount)) / 32768.0; double levelDb = 20 * Math.Log10(Math.Max(0.00001, rms)); // 转成 dB,方便映射进度条 Application.Current.Dispatcher.BeginInvoke(() => { LevelMeter.Value = Math.Clamp((levelDb + 60) / 60 * 100, 0, 100); }); }

这段代码的精髓在于用 RMS 而不是峰值来反映音量。峰值只代表瞬时最大振幅,对噪音很敏感;RMS 靠近人耳感知到的「响度」。换算成 dB 后,-60dB 到 0dB 是语音的常见区间,所以(levelDb + 60) / 60刚好可以映射到 0-100 的进度条范围。BeginInvoke保证 UI 更新不会阻塞录音回调,回调线程只管计算,不让它碰界面控件。

如果你还需要把录音实时转发到网络,把文件写入的writer替换成一个网络发送器即可,DataAvailable里拿到的e.Buffer是裸 PCM 流,加上时间戳就能封包。这个思路比先存文件再读文件转发会高效得多。

顺着这个方向再做深一步,就是给录音服务加自动增益和静音检测:同样在DataAvailable里算 RMS,小于某阈值就不写入文件,节省磁盘空间。我用过这个方案处理了几个小时的讲座录音,效果比预期好。从那以后我每次录音都会先强制走一遍「录 2 秒 → 看电平是否跳动 → 停止 → 回放 → 再调整参数」的流程,不再依赖盲录。这套实践步骤配合资源包里的完整工程代码,能帮你少走我当初踩过的大半弯路,希望帮到你。

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

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

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

立即咨询