简介:面向需要在Windows桌面应用中集成录音、播放以及简单音频分析功能的开发者,这份示例工程完整展示了基于.NET Framework 4.5和Visual Studio 2017的WPF实现方案。工程借助NAudio库中的WasapiLoopbackCapture进行声卡数据捕获,再通过MediaPlayer完成音频的加载、播放、暂停、继续与停止,并将这些操作封装为界面按钮事件,让读者看清从底层采集到UI交互的完整链路。资源共175个文件,主要包含45个C#源码、XAML界面布局、编译生成的程序集与界面资源,以及用于测试的wav音频和必要配置文件,压缩包整体仅3.18MB,结构完整、目录清晰,适合直接打开学习或作为项目模板快速改造。目前已有926人学习下载。通过该工程,读者可以掌握录音数据写入、播放器控制、本地音频文件时长与比特率读取等关键代码写法,同时了解NAudio与WPF整合时的常见处理方式,节省从零搭建和反复调试的时间,对开发聊天软件、教育工具、语音笔记等场景有直接参考价值。 做WPF上位机或者工具类应用的时候,录音和播放音频是特别容易撞上的需求。现场采集一段语音、做语音对讲前的试听、给测试程序加个声音提示,甚至是把麦克风输入存成样本数据,核心要解决的问题就两个:怎么录,怎么放。WPF本身并没有提供一个开箱即用的录音控件,但把NAudio这个库引进来之后,录音、播放、暂停、进度控制都能在C#里直接搞定,十几分钟就能跑通一条完整流程。这篇总结适合已经会写基本WPF页面、但没碰过音频采集的开发者,也适合想快速给现有项目加语音模块的同行参考。
1. 方案选型:先弄明白WPF里能做音频的几套路子
1.1 录音不是WPF的强项,选对库是关键
很多人一开始会去找WPF自带的MediaElement或者MediaPlayer,但这两个类主要负责播放,录音能力很弱。WPF的定位是界面框架,底层没有直接暴露麦克风采集API,硬要用也不是不行,得绕到WinRT的Windows.Media.Capture,但那种做法既要处理异步API,还要面对桌面应用和WinRT组件之间的类型转换问题,写起来并不顺手。
实际项目里我更推荐直接用NAudio。这是一个开源跨平台的音频处理库,封装了Windows底层waveIn、waveOut、DirectSound、WASAPI等一系列音频API,核心功能覆盖录音、播放、格式转换、音频处理、混音等。对WPF开发者来说,NAudio最大的好处是API设计非常直白:录音就是拉数据,播放就是喂数据,中间没有太多概念门槛。而且它支持.NET Framework和.NET 6/8,老项目新项目都能用。
市面上还有个常见选择是Windows.Media.Playback和MediaPlayer,但这个组合主要是面向UWP以及WinUI的,在纯WPF里使用多多少少会遇到兼容性摩擦。如果是做轻量级工具,不想引入第三方依赖,也可以只用系统自带的SoundPlayer,不过SoundPlayer只支持WAV格式,播放MP3之类的压缩格式会很尴尬。所以在项目里我一般优先用NAudio,它既能做录音,又能做播放,一套API走完,避免同时维护两套音频处理逻辑。
1.2 环境准备:新建项目并引入NAudio
先说环境,Visual Studio 2022,创建WPF应用程序,目标框架选.NET 8或.NET Framework 4.7.2都可以。NAudio 2.2.1针对这两种框架都有兼容包,直接NuGet安装就行。
在包管理器控制台里执行:
Install-Package NAudio或者在“管理NuGet程序包”界面搜索NAudio,选择最新稳定版安装。装完之后,你会看到依赖里多了NAudio.Core、NAudio.WinForms等子包,没关系,那是NAudio模块化之后的结果,我们直接用命名空间NAudio.Wave和NAudio.Wave.SampleProviders就够了。
这里提醒一句:如果项目最终要部署到32位系统,或者要兼容某些老声卡驱动,建议把目标平台显式设置成x86或x64,不要让“Any CPU”乱选。因为NAudio底层会加载本机音频模块,位数不一致时偶尔会出现莫名其妙的初始化失败。我在一个工控机上就踩过这个坑,IntPtr指针在64位进程里访问32位驱动返回的句柄,直接抛异常,后来统一改成x64才稳定。
2. 录音:把麦克风声音落到WAV文件
2.1 录音链路上的两个核心类
NAudio里做录音,最核心的配合是WaveInEvent和WaveFileWriter。
WaveInEvent负责从声卡采集PCM音频数据,它按照设定的格式持续产生数据块,每次采集完一批数据就会触发DataAvailable事件。这个事件发生在后台线程,所以绝对不能在事件里直接操作WPF控件。WaveFileWriter则负责把收到的原始音频数据按WAV容器格式写入文件,WAV文件本质上就是在PCM数据前面加一个44字节的头,这个库已经帮我封装好了。
整个录音过程可以理解成一根水管:声卡裸数据往WaveInEvent里流,DataAvailable事件是水龙头,WaveFileWriter是接水的桶。只要开着水龙头,数据就不断装进桶里,关掉水龙头(调用StopRecording),桶里就是完整的录音文件。
用这两个类组合,还有一个隐藏好处:录制过程中如果程序崩溃,已经写进文件里的数据仍然是一个合法WAV的骨架,只是没有文件头,可以用其他工具修复,不至于全部丢失。
2.2 一个能直接用的录音封装
我先贴一段实际项目里常用的录音类,放到WPF的普通类库里就能跑。
using NAudio.Wave; public class AudioRecorder : IDisposable { private WaveInEvent _capture; private WaveFileWriter _writer; public AudioRecorder(string filePath) { _capture = new WaveInEvent { WaveFormat = new WaveFormat(44100, 16, 1), BufferMilliseconds = 50 }; _writer = new WaveFileWriter(filePath, _capture.WaveFormat); _capture.DataAvailable += OnDataAvailable; _capture.RecordingStopped += OnRecordingStopped; } private void OnDataAvailable(object sender, WaveInEventArgs e) { // e.Buffer里就是麦克风采集到的原始PCM数据 _writer.Write(e.Buffer, 0, e.BytesRecorded); } private void OnRecordingStopped(object sender, StoppedEventArgs e) { _writer.Dispose(); _writer = null; _capture.Dispose(); _capture = null; } public void Start() { _capture.StartRecording(); } public void Stop() { if (_capture != null && _capture.CaptureState == CaptureState.Capturing) { _capture.StopRecording(); } } public void Dispose() { Stop(); _capture?.Dispose(); _writer?.Dispose(); } }这里有个细节值得注意:WaveFormat(44100, 16, 1)的三个参数分别是采样率、位深、声道数,在绝大多数Windows声卡上都能正常工作。BufferMilliseconds = 50表示每个音频缓冲区的大小是50毫秒,数值越小,录音延迟越低,但CPU占用会升高,系统负载高时还容易爆音。50毫秒对语音录音来说是兼顾延迟和稳定性的起步值。
RecordingStopped事件里释放资源是比较稳妥的做法,因为StopRecording()是异步的,真正停止并完成所有事件回调需要一点时间。如果在调用StopRecording后立刻释放_writer,有概率出现数据还没写完就被释放的“半截文件”。
2.3 采样参数选择和文件大小估算
录音参数不是随便拍的,要结合应用场景来定。
- 语音通话、语音指令:采样率8000Hz或16000Hz就够,位深16bit,单声道。
- 普通语音记录、会议录音:44100Hz,16bit,单声道或双声道,音质接近CD,文件体积中等。
- 需要做后期处理、频谱分析的音频:建议44100Hz或48000Hz,16bit以上,双声道。
文件大小可以直接算,公式很简单:
文件大小(字节) = 采样率 × (位深 / 8) × 声道数 × 录音秒数比如用44100Hz、16bit、单声道录一分钟:
44100 × 2 × 1 × 60 = 5292000字节 ≈ 5.29MB如果换成双声道,则翻倍到10.6MB。这也是为什么短语音用WAV问题不大,但长时间录音最好另存成MP3或AAC,NAudio里可以配合MediaFoundationEncoder或者引入LAME编码器做MP3压缩。核心流程还是先采集成PCM,再转码,不复杂,但需要单独写一段转换逻辑。
2.4 录制时的界面状态更新
因为DataAvailable事件在后台线程触发,若要更新UI上的录音时长、电平指示等状态,必须借助Dispatcher跳回UI线程。直接写控件会抛“调用线程无法访问此对象”的异常。
我习惯做一个定时刷新,不要在每次DataAvailable都调用Dispatcher。50毫秒一个缓冲,相当于每秒20次跨线程切换,太浪费。可以用System.Windows.Threading.DispatcherTimer,每500毫秒拉一次录音时长,更新界面。
private void OnTimerTick(object? sender, EventArgs e) { if (_recorder != null && _captureState) { RecordingTimeText.Text = DateTime.Now.Subtract(_startTime).ToString(@"hh\:mm\:ss"); } }这样做的好处是界面刷新频率稳定,CPU占用很低,同时也能避免频繁Dispatcher调用导致的卡顿。
3. 播放:把录好的音频再放出来
3.1 用WaveOutEvent播放WAV/MP3
录音存成WAV之后,播放就简单多了。我推荐用WaveOutEvent配合AudioFileReader。
AudioFileReader是NAudio里一个很实用的读取器,既能读WAV,也能读MP3,还会自动完成格式转换。WaveOutEvent负责把解码后的PCM数据送到声卡输出。它们两个配合,基本覆盖了日常所有本地音频播放需求。
using NAudio.Wave; public class AudioPlayer : IDisposable { private AudioFileReader _reader; private WaveOutEvent _output; public void Play(string filePath) { Stop(); _reader = new AudioFileReader(filePath); _output = new WaveOutEvent { DesiredLatency = 200 }; _output.Init(_reader); _output.Play(); } public void Stop() { _output?.Stop(); _output?.Dispose(); _reader?.Dispose(); _output = null; _reader = null; } public void Dispose() { Stop(); } }这里的关键是_output和_reader必须保存为类字段,不能声明成局部变量。因为WaveOutEvent底层依赖声卡的播放线程,一旦方法结束后对象被垃圾回收,音频马上会静默,甚至程序直接崩溃。我见过不少新手写完后点击播放没声音,十有八九就是因为这个生命周期问题。
3.2 暂停、继续和播放进度
WaveOutEvent提供了Pause()和Play(),可以实现暂停和继续。暂停时播放位置会保留在AudioFileReader.CurrentTime里,继续时从当前位置继续。
进度条显示可以这样做:
public TimeSpan CurrentTime => _reader?.CurrentTime ?? TimeSpan.Zero; public TimeSpan TotalTime => _reader?.TotalTime ?? TimeSpan.Zero; public float Progress => (float)(_reader.CurrentTime.TotalSeconds / _reader.TotalTime.TotalSeconds);界面上的ProgressBar用一个DispatcherTimer定期读取这两个属性即可。停止播放时,把进度归零。
有几个播放状态需要单独标记:_output.PlaybackState会返回Stopped、Playing、Paused,可以用它判断当前是否在播放中,避免按钮状态混乱。另外,WaveOutEvent.PlaybackStopped事件在停止、播放结束、设备拔出等情况下都会触发,不要在这个事件里做太重的清理,容易和主动调用Stop产生重复释放。
3.3 把录音和播放接到按钮上
简单写个界面交互,几个按钮就够了。XAML里放三个按钮:开始录音、停止录音、播放录音。
<Button Content="开始录音" Click="OnStartRecording" /> <Button Content="停止录音" Click="OnStopRecording" /> <Button Content="播放录音" Click="OnPlayRecording" /> <TextBlock x:Name="StateText" />后端逻辑:
private AudioRecorder _recorder; private AudioPlayer _player; private readonly string _tempFile = "record.wav"; private void OnStartRecording(object sender, RoutedEventArgs e) { _recorder = new AudioRecorder(_tempFile); _recorder.Start(); StateText.Text = "录音中"; } private void OnStopRecording(object sender, RoutedEventArgs e) { _recorder.Stop(); StateText.Text = "录音完成"; } private void OnPlayRecording(object sender, RoutedEventArgs e) { _player = new AudioPlayer(); _player.Play(_tempFile); }这套交互虽然简单,但已经能覆盖最基础的“录完放一下听效果”场景。实际产品里,建议把录音状态和播放状态拆成枚举,配合MVVM的绑定来驱动按钮可用性,避免出现录着音又去点播放这种并发操作。
4. 常见问题与排查记录
4.1 录音相关的高频问题
症状是点击开始录音后,界面不报错,但文件里一点声音都没有,或者播放出来是刺耳的杂音。
先检查设备选择。默认WaveInEvent用的是系统默认麦克风。如果电脑同时插了USB麦克风、耳机麦克风和内置麦克风,默认设备很可能不是你想要的。可以用WaveInEvent.DeviceCount枚举所有录音设备,把设备名列到下拉框里:
for (int i = 0; i < WaveInEvent.DeviceCount; i++) { var caps = WaveInEvent.GetCapabilities(i); Debug.WriteLine($"{i}: {caps.ProductName}"); }然后创建WaveInEvent时指定DeviceNumber属性。
还有一种是系统设置里麦克风隐私权限被关了。Windows 10/11默认对桌面应用访问麦克风有隐私开关,位置在“设置-隐私-麦克风”。如果程序跑起来后系统提示麦克风被禁用,去这里把权限打开。
4.2 播放相关的高频问题
播放最常见的是点击播放后无声,但录音文件用其他播放器打开又是正常的。
先判断_reader是否还在,代码里是不是把读取器声明成了局部变量,导致播放前就被回收了。然后看声卡输出设备有没有选对,多声卡环境下WaveOutEvent默认输出到系统默认播放设备,如果默认设备是个虚拟声卡,真实音箱不出声也正常。
还有一种情况是录出来的WAV采样率太高,老声卡不支持直接播放。比如用48000Hz录音,有些旧声卡只支持44100Hz,播放时就会有“变调”或者没声音。解决方法是播放前让AudioFileReader自动转换采样率,或者录音时直接用44100Hz,兼容性最好。
4.3 录音播放同时用时的资源冲突
WPF应用里有时候需要边录音边播放提示音,比如对讲系统的“滴”声。这时要特别注意声卡的独占模式。
WaveInEvent和WaveOutEvent默认走共享模式,多数声卡支持录音和播放同时进行。但如果程序里有人设置了底层的独占WASAPI,或者其他软件(如某些语音聊天工具)抢占了设备,你这边再录音或播放就会失败,报DeviceInUse类型的异常。
遇到这种问题,排查顺序是:先确认没有其他软件占用麦克风;再检查代码里是否同时创建了多个WaveInEvent实例,如果有,先停掉旧的再启动新的;最后考虑用WasapiCapture替代WaveInEvent获得更好的兼容性。WasapiCapture是WASAPI回调,延迟更低,但代码逻辑略有差异。
5. 升级一点:把录音播放做成MVVM服务
5.1 定义服务接口,方便后续替换
项目如果用了MVVM框架,比如Prism或者CommunityToolkit.Mvvm,建议把录音播放封装成服务接口,而不是在ViewModel里直接new一个具体类。这样以后换底层实现、加音频处理逻辑都方便。
public interface IAudioRecordingService { void StartRecording(string filePath); void StopRecording(); event EventHandler<TimeSpan>? RecordingTimeChanged; } public interface IAudioPlaybackService { void Play(string filePath); void Pause(); void Stop(); event EventHandler<bool>? PlayStateChanged; }这样ViewModel只依赖两个接口,具体是NAudio实现还是系统API实现,都跟界面层无关。
5.2 在ViewModel里调用录音播放
ViewModel里使用服务后,按钮命令变得非常干净。
public class VoiceViewModel { private readonly IAudioRecordingService _recordingService; private readonly IAudioPlaybackService _playbackService; public VoiceViewModel(IAudioRecordingService recordingService, IAudioPlaybackService playbackService) { _recordingService = recordingService; _playbackService = playbackService; } public ICommand StartRecordCommand => new RelayCommand(() => _recordingService.StartRecording("voice.wav")); public ICommand StopRecordCommand => new RelayCommand(_recordingService.StopRecording); public ICommand PlayCommand => new RelayCommand(() => _playbackService.Play("voice.wav")); }录音服务在实现时,把DataAvailable里产生的事件时间抛出来,ViewModel订阅后更新界面。这样主界面和音频逻辑彻底解耦,后续要做音频波形可视化、音量仪表,直接在服务里加事件就行,不会污染视图层。
实际项目里我建议把_tempFile路径放到配置里,并且启动时检测磁盘空间,因为长时间录音会在几分钟内产生几十甚至上百MB的WAV文件,如果没做控制,用户容易录到一半把C盘塞满。这个坑在我第一次做语音采集功能时遇到过,后来一直记着:录音不是“按下就完事”,内存、磁盘、线程、设备状态都要盯着。
NAudio这套方案在Windows平台上已经足够成熟,录音、播放、暂停、进度控制都能覆盖。如果你后面要做更复杂的实时音频处理,比如降噪、回声消除、频谱显示,也可以在这条技术路线上继续延伸。希望这次分享能帮正在做WPF录音播放功能的朋友少走几步弯路。
本文还有配套的精品资源,点击获取