简介:这份资源是一套基于.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而不是自带的MediaPlayer
1.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文件,每块拆得很干净。
我最终选它,核心原因是三点:
| 对比维度 | 自带MediaPlayer | NAudio |
|---|---|---|
| 录音采集 | 无 | 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 List<string> GetRecordingDevices() { var devices = new List<string>(); 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 EventHandler<double> 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×1=32000字节,也就是32KB每秒,录1分钟约1.92MB。如果是CD音质(44100Hz、16bit、双声道),每秒要176.4KB,1分钟超过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操作(比如直接更新TextBlock),WPF会抛跨线程访问异常;如果做了文件流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线程这三件事。把状态机和资源释放捋清楚,百分之八十的问题都不会发生。如果之后要扩展成实时波形显示或者通话录音,思路也是在这个基础上加长数据链路,核心的地基不会变。
本文还有配套的精品资源,点击获取