
简介这份资源是一套基于.NET Framework 4.5与Visual Studio 2017的WPF音频处理示例工程面向需要快速实现录音、播放及音频文件分析的桌面应用开发者。资源囊括了使用NAudio库进行声卡录音、通过MediaPlayer播放音频、在XAML界面中绑定按钮事件等核心代码并配有用于波形显示与图表绘制的相关控件可直接参考或迁移到聊天、教育等场景中。压缩包共175个文件约3.18MB其中45个C#源码与9个BAML/XAML界面文件构成主要实现13个DLL为NAudio等依赖库5个WAV文件可用于测试另含工程配置文件与PNG示意图结构清晰便于对照学习。目前已有926人学习下载不论新手快速上手还是开发者提取音视频处理逻辑都有一定参考价值。 最近手头有个WPF桌面项目要做质检工位的语音留痕现场人员按下按钮说话录完能回放还得把音频文件按工单号归档。需求听起来简单真做起来才发现录音和播放这块儿.NET自带的类库压根不够用。查了一圈资料最后用NAudio把整条链路打通了。这篇文章就围绕WPF里“录音播放音频”这件事把我从选型到落地的完整过程写出来。内容包含核心API用法、会遇到哪些坑、以及怎么把录音播放优雅地接进MVVM工程。适合正在做WPF桌面工具、上位机软件或者想给业务系统加语音模块的开发者参考。1. 为什么我选了NAudio而不是自带的MediaPlayer1.1 自带方案的真实体验凑合能用但很别扭很多人第一反应是用SoundPlayer或者老牌的MediaPlayer类。SoundPlayer只能播放WAV还不能控制进度和音量录音功能压根没有。MediaPlayer就是那个WinForm时代的控件WPF里也能用能播MP3和WAV但状态管理很隐晦触发事件容易在后台线程里乱跑想拿到实时波形或者录音数据更是没门。后来我又试了System.Windows.Media.Capture那套UWP的MediaCapture虽然能录音但在纯WPF项目里引用Windows Runtime API特别折腾——需要额外处理包引用、权限声明部署到没有摄像头麦克风权限的工控机上经常出幺蛾子。1.2 NAudio的地位和使用场景NAudio是一个开源音频库对Windows平台支持非常全。它包含录音采集、音频播放、格式转换、音频效果处理、波形生成、混音等等基本覆盖了桌面音频开发99%的需求。它的设计思路很直接WaveInEvent负责录音WaveOutEvent负责播放WaveFileWriter负责写WAV文件每块拆得很干净。我最终选它核心原因是三点对比维度自带MediaPlayerNAudio录音采集无WaveInEvent播放格式支持支持MP3/WAV支持WAV/MP3/AAC等更多格式音量/进度控制不灵活直接改Volume和CurrentTime底噪/波形处理无可以拿PCM原始字节做RMS等分析文件格式转换无可自由拼接和转码对于WPF项目来说NAudio还有一个好处它本身不依赖UI框架不绑控件和MVVM那一套配合起来很自然。你可以在ViewModel里直接引用音频服务UI只负责调用命令这样代码结构不会乱。1.3 为什么录音这个环节绕不开NAudio录音的本质是麦克风声卡把模拟信号采样成PCM数据再按WAV或者其他容器格式存下来。.NET自带的SoundPlayer完全没有采集能力MediaCapture又太重。而NAudio的WaveInEvent算是封装得比较轻的录音API——它帮我们管理了音频设备打开、数据缓冲区轮转、异常回调这些脏活我们只需要订阅一个DataAvailable事件往里写文件就行。所以结论很明确WPF项目要做录音和播放直接用NAudio是最省力、最可控的选择。接下来我会按录音和播放两条链路分别讲实现细节。2. 录音链路设备枚举、数据采集到WAV落盘的完整实现2.1 设备枚举该不该做很多示例代码上来就new WaveInEvent()直接录音根本不看当前机器上到底有哪些输入设备。这在开发机上行得通但部署到现场就会遇到问题有些工控机装了虚拟声卡、USB声卡默认设备可能不是你想要的麦克风。所以在启动录音之前最好先枚举一遍设备public Liststring GetRecordingDevices() { var devices new Liststring(); for (int i 0; i WaveInEvent.DeviceCount; i) { var capabilities WaveInEvent.GetCapabilities(i); devices.Add(capabilities.ProductName); } return devices; }拿到列表之后可以让用户在设置界面里选设备也可以直接用waveIn.DeviceNumber index指定设备。这个环节看起来多余但它是避免“明明插了麦克风却录不出声音”这类尴尬问题的第一道防线。2.2 录音核心代码从DataAvailable到WaveFileWriter先定义一个录音服务类把底层逻辑封装起来public class AudioRecorder : IDisposable { private WaveInEvent _waveIn; private WaveFileWriter _writer; private string _outputFilePath; public event EventHandlerdouble LevelUpdated; public event EventHandler RecordingStopped; public void Start(string outputFilePath, int deviceIndex 0) { _outputFilePath outputFilePath; _waveIn new WaveInEvent { DeviceNumber deviceIndex, WaveFormat new WaveFormat(16000, 16, 1), BufferMilliseconds 50 }; _waveIn.DataAvailable OnDataAvailable; _waveIn.RecordingStopped OnRecordingStopped; _writer new WaveFileWriter(outputFilePath, _waveIn.WaveFormat); _waveIn.StartRecording(); } private void OnDataAvailable(object sender, WaveInEventArgs e) { _writer.Write(e.Buffer, 0, e.BytesRecorded); double level CalculateLevel(e.Buffer, e.BytesRecorded); LevelUpdated?.Invoke(this, level); } private void OnRecordingStopped(object sender, StoppedEventArgs e) { _writer?.Dispose(); _waveIn?.Dispose(); RecordingStopped?.Invoke(this, EventArgs.Empty); } public void Stop() { _waveIn?.StopRecording(); } private double CalculateLevel(byte[] buffer, int bytesRecorded) { long sum 0; for (int i 0; i bytesRecorded; i 2) { short sample BitConverter.ToInt16(buffer, i); sum sample * sample; } return Math.Sqrt(sum / (bytesRecorded / 2)) / short.MaxValue; } public void Dispose() { Stop(); _writer?.Dispose(); _waveIn?.Dispose(); } }这段代码里几个关键点要展开解释采样率16000、16位、单声道这是语音录制常用的配置。电话语音就是这个标准文件体积也小每秒的数据量是16000×2×132000字节也就是32KB每秒录1分钟约1.92MB。如果是CD音质44100Hz、16bit、双声道每秒要176.4KB1分钟超过10MB对语音留痕场景完全没必要。BufferMilliseconds设为50意思是在数据回调时每隔50毫秒的声音数据攒一批返回给我们。这个值太小会导致CPU频繁回调太大又会让录音停止时最后一段数据迟迟拿不到。我实际测下来50到100毫秒是语音录制里比较平衡的值。WaveFileWriter.Write只做内存写入真正把WAV文件的44字节头部包括RIFF标识、数据长度等写完整是在DisposeMethod里。所以千万别忘了Dispose否则输出的文件很多播放器会直接打不开。2.3 录音时长限制和定时停止有些场景需要限制录音时长比如语音备注最长30秒。可以在服务里加一个TimeSpan maxDuration参数启动时开一个定时器到点自动调用Stop。WPF里这一步最好用System.Timers.Timer或者异步延时不要用DispatcherTimer因为我们这个录音服务是纯后台逻辑不应该绑定UI线程。2.4 录音文件命名和目录管理如果录音文件要长期留存建议按业务维度建目录比如Archive/2025/06/工单号_时间戳.wav。文件名里别用中文和特殊符号避免后续被文件传输或数据导入流程踩坑。另外写文件之前先确认目录存在Directory.CreateDirectory不会报错放心用。3. 播放链路从AudioFileReader到WaveOutEvent的调用细节3.1 为什么播放也用NAudio而不是MediaPlayer说实话MediaPlayer播放MP3和WAV都能胜任但在WPF里用它管理生命周期很烦设置MediaPlayer.Open之后Volume、Position这些属性不能立刻访问需要等MediaOpened事件播放结束事件MediaEnded有时会在非UI线程触发又得Invoke回UI线程更新状态而且多个MediaPlayer实例同时存在时资源释放稍不注意就会把音频设备搞挂。用NAudio的WaveOutEvent播放就清爽很多。它本质上是在后台线程里跑播放循环我们直接Init一个AudioFileReader然后Play就行了。public class AudioPlayer : IDisposable { private WaveOutEvent _playbackDevice; private AudioFileReader _audioFile; public event EventHandler PlaybackFinished; public void Play(string filePath) { Stop(); _audioFile new AudioFileReader(filePath); _playbackDevice new WaveOutEvent(); _playbackDevice.PlaybackStopped OnPlaybackStopped; _playbackDevice.Init(_audioFile); _playbackDevice.Play(); } public void Pause() { _playbackDevice?.Pause(); } public void Resume() { _playbackDevice?.Play(); } public void Stop() { _playbackDevice?.Stop(); _playbackDevice?.Dispose(); _audioFile?.Dispose(); _playbackDevice null; _audioFile null; } private void OnPlaybackStopped(object sender, StoppedEventArgs e) { if (_playbackDevice null) return; PlaybackFinished?.Invoke(this, EventArgs.Empty); } public void Dispose() { Stop(); } }3.2 播放进度、音量和时间更新播放进度是在WPF界面上经常要显示的东西。有两种做法一种是每个UI刷新周期从_audioFile.CurrentTime读取另一种是给AudioFileReader的CurrentTime直接赋值实现“跳转进度条”。读取进度很简单public TimeSpan GetPosition() { return _audioFile?.CurrentTime ?? TimeSpan.Zero; } public TimeSpan GetDuration() { return _audioFile?.TotalTime ?? TimeSpan.Zero; }在WPF里用DispatcherTimer每200毫秒刷新一次界面进度条和当前时间。200毫秒是个体验和性能的平衡点太快没意义太慢会觉得进度卡顿。音量控制也容易_playbackDevice.Volume的取值范围是0到1的浮点数驱动会直接映射到音频会话音量。顺带说一句如果想让音量变得平滑可以给Slider绑一个转换器把0-100的整数转成0.0-1.0的浮点数这个小细节可以避免声音突然变大吓到操作员。3.3 播放完成后的自动清理PlaybackStopped事件触发的原因很多正常播完、手动Stop、设备出问题都会走这个回调。所以在回调里要判断是“用户主动停止”还是“自然结束”可以通过_playbackDevice ! null来判断也可以自己维护一个_isManualStop标记。不及时清理播放设备的问题很隐蔽上次播放的WaveOutEvent没有Dispose下次再new WaveOutEvent时有时候会抛MmException: Already allocated的异常。这就是因为音频设备资源被第一个实例占着不放。所以我在Play方法的开头先调用一次Stop()确保上一个播放彻底释放。4. 录音播放最常踩的坑从静音到文件损坏的排查记录4.1 录出来全是静音这个坑我踩过很多次。表现是录音流程正常走完文件也有但播放出来一点声音没有波形是一条直线。排查链路是这样的第一步先用Windows自带录音机测硬件确认麦克风本身没问题。第二步检查WaveInEvent.DeviceNumber。如果你机器上有多个音频输入设备比如摄像头麦克风、USB声卡、蓝牙耳机默认设备不一定是实际插着的那个。我把设备枚举列表直接显示在界面下拉框里让测试人员自己选对了再录问题就定位到“选错设备”了。第三步检查WaveFormat。如果录入时用44100Hz双声道但实际麦克风只支持16kHz单声道驱动会自动做一次混音降采样极端情况下某些USB声卡会在混音时把数据清空。换回16000/16/1之后一切正常。第四步看DataAvailable里有没有数据。可以临时加个计数器打印确认回调是否在触发。如果回调没触发多半是WaveInEvent.StartRecording()没成功异常被RecordingStopped吞掉了。最坑的情况是代码在开发机上一切正常部署到一台老旧的工控机上就静音了。后来发现是那台机器的麦克风增益被系统设置成0了控制面板里把“麦克风增强”拉起来就好了。这种硬件层面的问题代码里没法完全规避但可以在界面上做一个“试录草稿”按钮让操作员录一秒自己听一下再开工算是务实的兜底方案。4.2 WAV文件损坏播放器打不开如果WaveFileWriter没有正确Dispose文件头里的数据长度字段就是错的Windows媒体播放器打开会报错VLC有时候能忍但业务系统的后续处理程序很可能直接崩溃。正确的关闭顺序是_waveIn.StopRecording(); // 先停采集 _writer.Dispose(); // 再写文件头落盘 _waveIn.Dispose(); // 最后释放设备千万别反过来。因为StopRecording()之后还有最后一帧数据可能留在缓冲区没回调如果先Dispose Writer这帧数据就丢掉了而且WAV头也不会正确更新。4.3 录音时界面卡顿DataAvailable回调是NAudio在后台线程调用的不是UI线程但如果在这个回调里做UI操作比如直接更新TextBlockWPF会抛跨线程访问异常如果做了文件流Flush则会造成录音数据频繁写入磁盘卡顿和爆音都可能出现。正确的做法是回调里只写内存文件流需要更新UI的数值比如音量级别通过轻量事件抛出去在ViewModel里用Application.Current.Dispatcher.BeginInvoke转给UI。WaveFileWriter底层自己会缓冲不需要每帧Flush。只有到停止录音时Dispose才真正把数据刷进磁盘。4.4 快速连续播放导致设备占用有一种场景用户连点两次录音机按钮第一次录音还没停第二次又开始了。这时老的WaveInEvent还没释放新的又来了就可能出现设备被占用。我在录音服务里加了一个简单的状态锁private bool _isRecording; public bool IsRecording _isRecording; public bool TryStart(string path, int deviceIndex) { if (_isRecording) return false; _isRecording true; // 启动逻辑 return true; }Stop时再把_isRecording置为false。这个小小的状态位挡住了大量因为用户连点造成的奇怪问题。4.5 录音转文字和语音模型需要的格式最近经常有人问“录完音怎么转文字”。如果后续要做语音识别录音参数的适配很关键。大多数识别引擎和语音大模型要求的音频格式是16kHz、16bit、单声道PCM。如果你在录音时就用这个格式后续接第三方ASR服务时可以省掉转码步骤直接传文件。如果拿到的音频已经是44.1kHz也可以用NAudio做重采样var reader new AudioFileReader(sourceFile); var resampler new WaveFormatConversionStream( new WaveFormat(16000, 16, 1), reader); WaveFileWriter.CreateWaveFile(targetFile, resampler);这一段代码对于做本地语音AI工具的人来说很实用很多桌面语音应用在上传语音前都会做一次这样的规整。5. 把录音播放改成MVVM风格的状态机工程化才是正经事5.1 状态机设计空闲、录音中、播放中如果只是写个Demo直接在Button的Click事件里调录音播放也没什么问题。但放到正式WPF工程里尤其用了MVVM框架状态管理就得认真设计了。我把录音播放抽象成三个状态Idle、Recording、Playback并暴露一个枚举属性public enum AudioPlayerState { Idle, Recording, Playing }ViewModel里维护一个CurrentState属性所有命令的CanExecute都跟这个状态联动。比如Idle状态下“开始录音”可用“播放”可用。Recording状态下“开始录音”禁用“停止录音”可用。Playing状态下“停止播放”可用“开始录音”禁用避免边录边放的混乱。这样做的好处是界面逻辑永远不可能进入非法状态。用户连点也好、快捷键触发也好命令状态会直接拦住。5.2 RelayCommand和IDisposable的正确姿势既然用了MVVM命令和资源释放是绕不开的。我习惯用一个轻量的RelayCommand实现网上很多Prism、CommunityToolkit.Mvvm也都自带。关键是CanExecute要绑定CurrentState并且状态变化时通知命令重新评估可用性private void OnCurrentStateChanged() { OnPropertyChanged(nameof(CurrentState)); StartRecordingCommand.RaiseCanExecuteChanged(); StopRecordingCommand.RaiseCanExecuteChanged(); PlayCommand.RaiseCanExecuteChanged(); StopPlayCommand.RaiseCanExecuteChanged(); }关于IDisposable我的建议是AudioRecorder和AudioPlayer都在ViewModel的Dispose里释放。很多WPF项目里ViewModel的生命周期往往和窗口、业务服务是一起销毁的但人们经常忘记释放音频设备。如果不处理窗口关掉后音频设备还占着下次打开新窗口就报设备被占用。5.3 录音时间和电平显示给录音按钮旁边放一个“正在录音 00:12”的提示是刚需。我实现的方式是启动录音时开一个DispatcherTimer_timer new DispatcherTimer { Interval TimeSpan.FromMilliseconds(200) }; _timer.Tick (s, e) { ElapsedTime ElapsedTime.Add(TimeSpan.FromMilliseconds(200)); }; _timer.Start();录音停止时记得_timer.Stop()并把ElapsedTime归零。电平指示可以用录音服务抛出来的LevelUpdated事件把RMS值映射成界面上一个水平进度条的宽度。数据更新频率高每50毫秒一次所以更新UI时要用Dispatcher.BeginInvoke避免200ms一次的事件在UI线程堆积。5.4 文件选择的UI策略录音文件保存到哪里不能写死。我用了一个简单的策略默认保存到“我的文档”下的一个VoiceRecords目录界面显示完整路径旁边放一个“打开文件夹”按钮用Process.Start(explorer.exe, path)打开系统文件管理器。这比做一个大而全的文件浏览窗口省事得多而且用户体验也很好。如果项目里已经用了Microsoft.Win32.OpenFileDialog做导入也可以用同一个套路做文件选择但考虑到录音文件往往是自动归档的让用户手动选路径反而容易把文件搞乱。5.5 热更新和扩展方向的思考最近看到有人讨论“录音模块热更新时如何确保已存WAV文件零损坏”这里说下我的做法所有录音文件的写盘和WAV头补写全部在内存缓冲完成后一次性交给WaveFileWriter闭合并刷新。在这种设计下即使录音模块要热更新也需要先优雅停止录音等RecordingStopped事件触发确认文件已经正确落盘再执行模块替换。强杀进程导致的文件损坏靠应用层内无法完全避免只能尽量缩短Stop到Dispose之间的时间窗口。如果以后要做语音分析和转写录音数据的采集参数在上线前就定好16kHz/16bit/单声道会为后续索引、切分、识别节省大量时间。这也是我在项目上线前反复跟团队强调的一点录音的格式参数是接口契约不是实现细节定了就不能随便改。我个人在实际项目里的体会是录音播放这个功能代码量不大但坑都在细节里尤其设备占用、WAV头写入、UI线程这三件事。把状态机和资源释放捋清楚百分之八十的问题都不会发生。如果之后要扩展成实时波形显示或者通话录音思路也是在这个基础上加长数据链路核心的地基不会变。本文还有配套的精品资源点击获取