ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WPF录音与播放音频实战:从NAudio采集到MVVM集成

WPF录音与播放音频实战:从NAudio采集到MVVM集成 简介这份WPF音频开发资源定位于.NET Framework 4.5与Visual Studio 2017环境面向需要为聊天、教育或工具类应用加入录音、播放及本地音频分析能力的开发者。资源以NAudio库和System.Windows.Media.MediaPlayer为核心提供了完整的WasapiLoopbackCapture录音实现、MediaPlayer播放暂停恢复停止控制以及通过AudioFileReader读取音频时长的代码示例并给出WPF按钮事件绑定思路。压缩包共175个文件大小约3.18MB含45个C#源码、13个DLL依赖、5个WAV测试音频、32张界面截图以及XAML、BAML、resources等资源文件可直接对照项目结构进行学习或复用尤其适合想快速上手WPF音频集成的初中级开发者。目前已有926人学习下载配套代码经过实际编译能有效减少摸索成本帮助读者快速落地音频录制与回放功能。1. 在 WPF 里做录音和播放音频为什么不能只靠框架本身如果你新建一个 WPF 项目然后把工具箱翻个底朝天会发现根本找不到“录音”这个控件。这不算缺陷而是 WPF 的定位问题它负责界面不负责音视频采集。真正需要「wpf 录音和播放音频」的人通常在做会议纪要、语音备忘、考勤签到这类桌面小工具——界面不复杂但麦克风采集、WAV 文件格式、播放进度这些事框架一件都没替你完成。更麻烦的是WPF 自带的播放方案 MediaElement 已经处于半淘汰状态新手照着旧教程写出来一运行就发现性能或兼容性问题。这篇文章直接按我平时交付的思路来讲先选对音频 API再处理录音落盘、回放最后落到 MVVM 集成把常见翻车点摊开说。新手能照步骤跑通熟手也能绕开我踩过的坑。2. 先选对音频 API录音和播放分别在哪条技术栈上2.1 录音端框架没有录音控件三条可选路线WPF 没有内置录音能力这一点先接受它。目前从业者实际在用的路线有三条我按常用程度排一下。第一条是用 NAudio 开源库做音频采集。它直接封装了底层 WaveIn / WASAPI / ASIO 等接口能在 DataAvailable 事件里拿到原始 PCM 字节流。这对后续做电平指示、波形绘制、暂停续录都非常关键因为一旦拿不到裸数据上面这些功能全得靠黑匣子猜。NAudio 也是目前社区里维护最活跃的 .NET 音频库遇到问题能搜到大量现成案例。第二条是用 Windows.Media.Capture 里的 MediaCapture API。这是 WinRT 的程序集WPF 项目需要先添加对 Windows 的引用再通过 await 方式初始化录音。优点是能直接拿到编码后的音频文件比如 mp3 或 m4a不需要自己处理 WAV 头缺点是和 WPF 的桌面消息循环配合起来别扭UI 线程同步模型不一样做回调时动不动就要 Dispatcher 转场。而且 WinRT 在不同 Windows 版本上的表现略有差异一旦遇到设备异常排查成本比 NAudio 高不少。第三条是 P/Invoke 调用 winmm.dll 里的 mciSendString属于“上古方案”。写起来很短录音、播放都能用字符串命令驱动但错误信息极其简陋连“设备忙”都经常只返回一个通用错误码。我自己的态度是演示可以交付别用。它不支持现代 API 的精细控制做不了多设备枚举也拿不到可靠的电平数据。三条路的取舍可以浓缩成下表路线拿到的数据UI 集成交付风险NAudioPCM 裸字节事件驱动数据可直接绑到界面低社区案例多MediaCapture编码后的文件需处理 WinRT 异步和线程切换中版本差异多mciSendString文件或设备状态字符串命令错误难定位高只适合玩具2.2 播放端MediaPlayer、SoundPlayer 与 NAudio 的边界播放端的选择比录音端更微妙因为 WPF 其实自带两个播放器SoundPlayer 和 MediaPlayer。很多 WPF 基础教程只提 MediaElement那是历史原因MediaElement 基于旧版 DirectX 渲染路径做视频还有一席之地做音频只有杀鸡用牛刀的感觉还会拖着一个不可见的渲染表面。SoundPlayer 只能播 WAV优点是代码量极小缺点是它是“发射后不管”的设计没有可靠的播放完成事件也没有音量/进度控制。做演示项目还好做正经工具根本不够用。MediaPlayer 是 WPF 里被低估的正确选项。它支持 WAV、MP3、WMA 等常见格式有 Volume、Position、NaturalDuration 这些属性MediaEnded 事件也能稳稳触发。但它有一个脾气一个 MediaPlayer 实例同时只能开一个媒体源想在列表里连续播放不同文件就得复用同一个实例并重新 Open或者干脆维护多个实例。另外它必须在 STA 线程上使用好在 WPF 的 UI 线程本来就是 STA这点天然满足。NAudio 在播放端同样有用。WaveOutEvent 配合 WaveFileReader 可以把 WAV 字节流直接塞给声卡适合播放刚从内存生成的录音省掉临时落盘。它还支持同时开多个 WaveOutEvent 实现混音这在做音效叠加时是刚需。代价是它只认裸 PCM 或 WAV 这类未压缩格式MP3 得先转成 PCM 才能播。我的分配习惯是播放本地 WAV/MP3 文件用 MediaPlayer播放内存里的录音字节用 NAudio两边各干各的不混用。2.3 我为什么默认用 NAudio兼顾交付速度与调试自由度如果你只让我给一个“最不容易后悔”的组合那就是录音用 NAudio播放文件用 MediaPlayer播放内存 WAV 用 NAudio。原因很实际NAudio 的 WaveInEvent 开箱即用NuGet 一条命令装完就能出声同时它把设备枚举也暴露出来了可以查出系统装了哪些录音设备、哪个是默认设备。这对桌面工具太重要了——用户换了个 USB 麦克风程序不能假装没看见。安装命令很简单dotnet add package NAudio装的是 NAudio 2.x。这个版本里命名空间还是 NAudio.Wave类名和 1.x 时代一致所以老博客的代码基本还能跑。唯一要注意的是网上大量示例写new WaveInEvent(deviceNumber)这种带参构造函数在 2.x 里已经删掉了得改成先 new 出来再赋值 DeviceNumber。如果编译报错九成是这个问题。选 NAudio 还有一个隐性收益它的数据流是“推”模式。DataAvailable 事件由后台录音线程触发你在里面拿到的是字节数组不是封装好的文件对象。这意味着可以自己做静音检测、自动分段、电平表甚至直接喂给可视化控件。相比之下MediaCapture 给你的是已经封装好的文件或者流想做中间处理要么绕一大圈要么只好放弃。3. 实现录音从麦克风收音到落盘 WAV 文件3.1 用 WaveInEvent 拉起麦克风最小可用代码先跑通“能录、能停”的最小链路。下面这段代码放到窗口代码里声明一个成员字段_pcmChunks用来暂存 PCM 数据。using NAudio.Wave; private readonly Listbyte[] _pcmChunks new(); public void StartRecording() { var waveIn new WaveInEvent { DeviceNumber 0, // 0 表示系统默认录音设备 WaveFormat new WaveFormat(44100, 16, 1), // 44.1kHz / 16bit / 单声道 BufferMilliseconds 50 // 每次回调的缓冲时长 }; waveIn.DataAvailable (s, e) { var chunk new byte[e.BytesRecorded]; Array.Copy(e.Buffer, chunk, e.BytesRecorded); lock (_pcmChunks) { _pcmChunks.Add(chunk); } }; waveIn.StartRecording(); _waveIn waveIn; }这段代码的逻辑是WaveInEvent 启动后后台线程会每 50 毫秒抛一次 DataAvailablee.Buffer 是内部缓冲e.BytesRecorded 才是本次有效数据长度。必须按 BytesRecorded 拷贝直接引用 e.Buffer 会导致后续数据被覆盖。参数上有三个可以调的点。SampleRate 用 44100 是求稳电话音质的语音备注用 16000 就够了文件体积直接小一半BitsPerSample 建议固定 16兼容性最好BufferMilliseconds 调小到 20 能降低录音延迟但 CPU 占用会上去做波形显示时再考虑普通录音保持 50 就行。3.2 把裸 PCM 拼成 WAV 头不补头播放器全都不认如果你直接把_pcmChunks拼起来写成文件用 PotPlayer 或 Windows 自带的播放器打开它会提示“无法播放”。原因很简单WAV 不是裸 PCM它需要 44 字节的文件头告诉播放器采样率、位深、声道数以及数据段从哪里开始。WAV 头结构是固定的我直接列出来偏移长度内容04RIFF 标识44文件总长度减 884WAVE 标识124fmt 标识164fmt 块长度固定 16202编码格式PCM 为 1222声道数244采样率284字节率采样率 × 块对齐322块对齐声道数 × 位深 / 8342位深364data 标识404数据段字节长度这个头有两个长度字段是“后来才能填”的偏移 4 的文件总长度和偏移 40 的数据长度。所以正确做法不是录完了再拼头而是先写一个占位头录的过程中不断追加数据停止录音后再回到文件头部把两个长度补上。下面这段代码把“写占位头”和“回填长度”封装成两个方法private FileStream _file; private long _dataStartPosition; private void InitializeWavFile(string path, WaveFormat format) { _file new FileStream(path, FileMode.Create); using var writer new BinaryWriter(_file, System.Text.Encoding.ASCII, true); writer.Write(System.Text.Encoding.ASCII.GetBytes(RIFF)); writer.Write(0); // 占位停止后再回填 writer.Write(System.Text.Encoding.ASCII.GetBytes(WAVE)); writer.Write(System.Text.Encoding.ASCII.GetBytes(fmt )); writer.Write(16); writer.Write((short)1); // PCM writer.Write((short)format.Channels); writer.Write(format.SampleRate); writer.Write(format.AverageBytesPerSecond); // 字节率 writer.Write((short)format.BlockAlign); // 块对齐 writer.Write((short)format.BitsPerSample); writer.Write(System.Text.Encoding.ASCII.GetBytes(data)); writer.Write(0); // 占位停止后再回填 _dataStartPosition _file.Position; } private void FinalizeWavFile() { var dataSize _file.Position - _dataStartPosition; _file.Position 4; using var writer new BinaryWriter(_file, System.Text.Encoding.ASCII, true); writer.Write((int)(dataSize 36)); // 文件总长度 - 8 _file.Position _dataStartPosition - 4; writer.Write((int)dataSize); _file.Flush(); _file.Dispose(); }BinaryWriter 的第三个参数leaveOpen: true是关键它保证 using 结束后 FileStream 不关闭后续还能继续写数据。_dataStartPosition记录的是 data 段负载开始的位置而 data 段长度字段就在它前面 4 字节所以回填时用_dataStartPosition - 4。有了这两个方法录音的 DataAvailable 里就不再需要_pcmChunks直接_file.Write(e.Buffer, 0, e.BytesRecorded)即可。停止录音时调用 FinalizeWavFile文件立刻变成能播放的合法 WAV。3.3 录音时长、电平指示和停止逻辑录音过程中用户最关心两件事已经录了多久以及麦克风是不是真的在收音。时长不需要用计时器累加直接按字节数换算最准确。PCM 每秒产生的字节数就是 WaveFormat.AverageBytesPerSecond所以seconds totalBytes / format.AverageBytesPerSecond。电平指示则要读最近一块 PCM 数据把 byte 对转成 short 样本再算 RMS 值。代码可以这样写private float CalculateLevel(byte[] buffer, int bytesRecorded) { if (bytesRecorded 2) return 0f; long sum 0; int sampleCount bytesRecorded / 2; for (int i 0; i sampleCount * 2; i 2) { short sample BitConverter.ToInt16(buffer, i); sum sample * sample; } return (float)Math.Sqrt(sum / (double)sampleCount) / short.MaxValue; }返回值在 0 到 1 之间可以直接绑到一个 ProgressBar 的 Value 上。注意这个计算不能在录音线程里直接更新 UI要用 Dispatcher.BeginInvoke 切回 UI 线程而且不要每 50 毫秒都切一次用一个 200 毫秒的 DispatcherTimer 去轮询最近一次的计算结果即可。停止录音这里有个细节WaveInEvent.StopRecording() 不是同步结束它会触发 RecordingStopped 事件真正的收尾逻辑应该放在事件回调里。如果你在 UI 线程里同步等待停止完成很容易把界面卡成“未响应”。我习惯的做法是停止按钮只调用 StopRecording然后在 RecordingStopped 里做 FinalizeWavFile、释放对象、更新 ViewModel 的 IsRecording 状态。这样整个流程没有任何一个阻塞点。4. 实现播放本地文件回放与内存回放4.1 文件播放用 MediaPlayer 读绝对路径录音存成 WAV 后最简单的播放方式是用 WPF 自带的 MediaPlayer。它支持 WAV 和 MP3足够覆盖大多数场景。最小代码是这样using System.Windows.Media; private MediaPlayer _player; public void PlayFile(string filePath) { StopPlayback(); _player new MediaPlayer(); _player.MediaOpened (s, e) { /* 此时才能安全读取时长 */ }; _player.MediaEnded (s, e) { /* 播放完成更新状态 */ }; _player.Open(new Uri(filePath, UriKind.Absolute)); _player.Play(); }这里第一个坑就是 Uri 必须用 Absolute。很多初学者写成new Uri(filePath)或者 Relative结果文件明明存在却播放失败。第二个坑更隐蔽MediaPlayer 如果没有被字段引用局部变量会在方法返回后被垃圾回收声音刚响一下就断了。务必把它存成类字段。MediaPlayer 还有一点要注意它只能同时播放一个媒体源。如果列表里点选了 A 文件又点 B 文件必须先调用_player.Close()再重新 Open。我一般会封装一个 StopPlayback 方法统一处理 Close、置空事件、恢复按钮状态。4.2 内存播放刚录完不落盘就回放有些工具要做“录音试听”录完立刻点播放不产生中间文件。这时再用 MediaPlayer 就不合适了因为 MediaPlayer 只认文件路径或者 URI不接受字节数组。用 NAudio 的 WaveOutEvent 可以直接在内存里播放。做法是把带 WAV 头的完整字节数组放进 MemoryStream交给 WaveFileReader 读取然后 WaveOutEvent 将读取器作为播放源启动。using NAudio.Wave; private WaveOutEvent _waveOut; private WaveFileReader _reader; private MemoryStream _memoryStream; public void PlayWavBytes(byte[] wavBytes) { StopPlayback(); _memoryStream new MemoryStream(wavBytes); _reader new WaveFileReader(_memoryStream); _waveOut new WaveOutEvent(); _waveOut.Init(_reader); _waveOut.Play(); } public void StopPlayback() { _waveOut?.Stop(); _waveOut?.Dispose(); _reader?.Dispose(); _memoryStream?.Dispose(); _waveOut null; _reader null; _memoryStream null; }这段代码的前提是 wavBytes 已经是完整的 WAV 文件数据也就是已经包含 44 字节头。如果你手里只有裸 PCM 字节需要改用 RawSourceWaveStream 并手动构建 WaveFormat否则播放出来的全是噪声。WaveOutEvent 是另一个后台线程在拉数据所以播放过程中更新 UI比如进度条、播放状态必须用 Dispatcher 切线程。和 MediaPlayer 不同WaveOutEvent 可以多开但做普通工具不建议多开一是音量叠加容易破音二是设备占用冲突更难排查。4.3 播放进度与录音状态的联动做法进度条是播放功能里最容易做成“看起来卡住”的部分。MediaPlayer 有 Position 属性但它在 MediaOpened 之前不可用WaveOutEvent 本身没有 Position 属性需要从 WaveFileReader 上读 CurrentTime 和 TotalTime。我常用的做法是播放开始时启动一个 250 毫秒的 DispatcherTimer每次 Tick 里根据当前播放器类型读取进度再更新 ViewModel 里的 Progress 和 PositionText。停止或播放完成时停掉 Timer并把进度归零。_progressTimer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(250) }; _progressTimer.Tick (s, e) { if (_waveOut ! null _waveOut.PlaybackState PlaybackState.Playing _reader ! null) { PositionText ${_reader.CurrentTime:mm\\:ss} / {_reader.TotalTime:mm\\:ss}; Progress _reader.CurrentTime.TotalSeconds / _reader.TotalTime.TotalSeconds * 100; } };用字符串插值格式化时长时一定要写成{...:mm\\:ss}冒号前加转义。很多人在这里直接写mm:ss编译不报错但运行时会抛 FormatException属于典型“玄学报错”。Progress 的百分比计算也要先判断 TotalTime 不为零否则除零异常会在 Timer 线程里冒出来截获起来更麻烦。5. WPF 录音播放避坑手册5 个我翻过车的场景5.1 保存的 WAV 在播放器里显示时长不对现象文件能打开但显示时长要么只有 0.1 秒要么是实际时长的两倍。原因WAV 头的 data 长度字段没有回填或者声道数填错。播放器读取头信息时会根据 data 段长度来计算总时长。双声道录音如果头里填了单声道播放器会按错误字节率算时长结果直接翻倍。解决用上文 FinalizeWavFile 方法停止录音时回填偏移 4 和偏移 40 两个字段。保存后建议立刻用 FileStream 重新打开检查文件长度是否等于44 dataSize。这不是可选步骤是录音功能交付的底线。5.2 录出来的声音全是沙沙声现象文件能播放但听到的全是白噪声人声完全被淹没。原因写入 WAV 头时格式描述和实际 PCM 数据不一致。最常见的是头里写 16 位实际数据是从 e.Buffer 直接拷出来的 16 位没错但采样率不一致还有的是在 DataAvailable 里反复调用 InitializeWavFile导致每个数据块前面都被塞了一个 WAV 头。解决录前把 WaveFormat 实例存成字段写头和后续播放都用同一个实例头只初始化一次DataAvailable 里只追加裸数据。如果已经录出噪声先用十六进制编辑器打开文件确认偏移 20 到 34 之间的 fmt 字段和录音参数一致再查别的。5.3 点击停止按钮后界面卡死现象停止录音时界面转圈几秒后才恢复甚至直接提示“未响应”。原因在 UI 线程里同步等待录音停止。WaveInEvent.StopRecording 是异步的有人会在 UI 线程上调用.Wait()或者用 ManualResetEvent 阻塞等待事件结果录音线程和 UI 线程互相等就卡死了。解决停止逻辑只做一件事——调用 StopRecording然后立即返回。真正的收尾在 RecordingStopped 事件里做。同理WaveOutEvent.Dispose 也尽量不要在 UI 线程直接调用先 Stop 再异步释放。这条血泪经验适用于整个 NAudio 家族永远不要在 UI 线程同步等待音频事件。5.4 录完立刻播放没声音或只播 0.1 秒现象点“停止录音”后立刻点“播放”要么完全没声音要么只响一声就停。原因录音文件还没写完。FinalizeWavFile 里 Flush 之后文件流可能还持有写锁播放器打开的是锁住的文件或者文件虽然存在但 data 长度字段还是 0播放器读到空数据。解决FinalizeWavFile 完成后把 _file 置空并确认已 Dispose播放前检查文件长度大于 44 字节。更稳妥的做法是录音结束事件触发后再允许播放按钮的 CanExecute 返回 true从交互层面直接避免竞态。5.5 麦克风权限没开程序不报错但录不到声音现象程序启动正常点击录音后电平一直为零Windows 系统录音机却能正常录。原因Windows 10/11 对桌面应用有麦克风隐私权限。如果权限关闭应用不会收到异常只会拿到全零数据。企业域环境下还会出现权限开关是灰的无法自行开启的情况。解决程序启动时枚举录音设备如果设备数为零或打开失败弹窗提示去“设置 - 隐私 - 麦克风”里允许桌面应用访问域环境则提示联系管理员。同时录音界面上做一个实时电平条让用户在录音前先“吹一下”确认有输入比任何权限检测都直观。6. 把这套录放能力收进 wpf mvvm三个我常用的落地技巧6.1 把录音机和播放器封装成服务类录音和播放功能一旦写进窗口代码里后期加 ViewModel 就得重构。我习惯先用两个接口把行为定义清楚再各写一个基于 NAudio 的实现类这样 UI 层永远不直接碰 WaveInEvent 和 WaveOutEvent。public interface IAudioRecorder { bool IsRecording { get; } void Start(string outputPath); void Stop(); } public interface IAudioPlayer { bool IsPlaying { get; } void PlayFile(string path); void PlayBytes(byte[] wavBytes); void Stop(); }接口的核心价值是测试时可以替换成假实现而且 ViewModel 只依赖接口不依赖具体库。以后想从 NAudio 换成 MediaCapture只需要动实现类整个 UI 层不动。6.2 录音按钮的 wpf 数据绑定要绑定状态而不是动作把按钮的 Command 绑定到 ToggleRecordCommand 很容易但这个命令在录音过程中必须“自己知道自己该干嘛”。设计上不要写两个命令分别给开始和停止而是绑一个切换命令CanExecute 由服务类状态决定。public ICommand ToggleRecordCommand new RelayCommand( execute: () { if (_recorder.IsRecording) _recorder.Stop(); else _recorder.Start(_tempFilePath); }, canExecute: () !_player.IsPlaying);这里 hasPlayed 之类的布尔变量都不需要状态全在服务对象里。CanExecute 返回 false 时wpf 数据绑定会自动置灰按钮用户一眼就能看出当前不能录音比弹窗提示礼貌得多。按钮的内容也可以直接绑IsRecording用样式里的 DataTrigger 在“录音中”和“开始录音”之间切换。6.3 我交付前必做的三重释放与最后一个小习惯第一重录音对象。WaveInEvent 实现 IDisposable但只调用 Dispose 还不够要等 RecordingStopped 事件触发后再 Dispose否则底层句柄可能还没释放干净。第二重播放对象。WaveOutEvent 停止后要立即 Dispose并把 reader、stream 引用置空否则下次 PlayBytes 会报“设备正在使用”。第三重窗口关闭兜底。在 Window.Closing 里检查两个服务是否还在工作如果录音中就先 Stop 并等待事件如果播放中就先 Stop。除了这三重释放我还有一个坚持了很多年的习惯录音前总是先看电平条有没有反应再让用户正式开口。这个小动作成本极低但能挡掉大量“设备没选对”“权限没开”“线没插好”之类的环境问题。每一次省掉这一步后来基本都要花十倍时间排查环境。这套录放方案做下来WPF 本身负责的只是按钮、进度条和数据绑定真正干活的是 NAudio 的事件驱动模型。希望这篇笔记能帮你少走我走过的弯路也希望你能把电平提示和停止流程做成肌肉记忆让录音功能在用户手里第一次点下去就有声音。本文还有配套的精品资源点击获取
返回列表