资讯动态

WPF音频开发实战:NAudio实现录音、播放与文件保存全链路

发布时间:2026/10/20 18:39:20 来源:尧图企业网站定制
简介这是一份面向.NET 4.5与Visual Studio 2017开发者的WPF音频处理实战示例聚焦在桌面应用中实现录音、播放与本地音频分析三大能力适合正在开发聊天工具、教育软件或需要多媒体交互功能的初中级WPF开发者参考。资源包共175个文件约3.18MB以45个cs源码文件为核心配合4个xaml界面文件、9个baml编译资源、13个dll依赖库及5个wav测试音频另有png截图、config配置、sln解决方案与csproj工程文件结构完整可直接编译运行。内容围绕NAudio的WasapiLoopbackCapture录音、MediaPlayer播放控制、音频时长与比特率读取等知识点展开并演示如何通过按钮事件将录音与播放功能接入WPF界面。目前已有928人学习下载可帮助读者快速理解音频采集与播放的代码组织方式掌握多媒体功能与WPF UI的整合思路并在此基础上扩展错误处理与多线程优化。1. 从一次“录完没声”的排查说起WPF 里录音和播放音频到底怎么做有个做桌面端工具的朋友找我说他在 WPF 项目里加了个录音按钮点下去能生成 wav 文件但用系统播放器打开是杂音用他自己写的播放按钮点下去又直接抛异常。我让他把代码发过来一看就笑了——录音用的是NAudio的WaveInEvent播放却用System.Media.SoundPlayer去读一个还在写入的文件流两个库对音频格式和流位置的理解完全不在一个频道上。这类问题在 WPF 音频开发里太常见了录音、播放、保存、回放看起来是四个独立动作实际上共享同一套音频格式、缓冲区大小和流生命周期。这份资源围绕的就是这个场景——在 WPF 里把麦克风采集、实时播放、文件保存和回放串成一条能跑通的链路。适合正在做会议记录、语音笔记、在线教学工具或者任何需要“说话就能存下来”的桌面应用开发者。如果你只是想在 WPF 里放个提示音那SoundPlayer就够了但只要涉及录音就必须认真选一套音频库并且把格式和流的管理从头理清楚。2. 技术选型与项目骨架为什么是 NAudio 而不是 SoundPlayer2.1 WPF 原生音频能力的边界WPF 本身对音频的支持非常薄。System.Media.SoundPlayer只能播放 wav 文件而且必须是一个完整的、可随机访问的流MediaPlayer控件虽然能播 mp3但它是面向媒体播放场景的延迟高、无法拿到原始采样数据更没法做录音。换句话说WPF 原生 API 里根本没有“录音”这个能力。要做麦克风采集必须落到 Windows 的波形音频接口waveIn或者更底层的 WASAPI 上。直接调 Win32 API 不是不行但回调管理、缓冲区对齐、格式转换这些细节足够写出一堆 bug。所以在这个项目场景下合格开发最可能用的做法就是引入一个成熟的音频封装库把底层细节挡在外面。2.2 NAudio 的定位与核心对象NAudio 是 .NET 生态里用得最广的音频库之一它把 waveIn、waveOut、WASAPI、DirectSound 这些接口统一封装成 C# 对象。在这个项目里核心用到的是三个类WaveInEvent负责从麦克风采集音频事件驱动不依赖窗口消息适合 WPF 这种 UI 线程模型。WaveFileWriter把采集到的字节流写成标准 wav 文件自动处理 RIFF 头。WaveOutEvent负责播放可以从文件读也可以从内存流读。这三个类共享同一个格式描述对象WaveFormat。录音和播放能不能对上全看这个格式有没有统一。我一般会在项目里定义一个静态的AudioFormat常量录音端和播放端都引用它避免出现“录的时候 44.1kHz播的时候按 16kHz 解”这种低级翻车。2.3 项目目录结构建议这份资源如果是一个可运行的 WPF 工程目录大致会是这样AudioDemo/ ├── App.xaml ├── MainWindow.xaml ├── MainWindow.xaml.cs ├── Audio/ │ ├── AudioRecorder.cs // 录音封装 │ ├── AudioPlayer.cs // 播放封装 │ └── AudioFormat.cs // 统一格式定义 ├── Models/ │ └── AudioClip.cs // 录音片段模型 └── Assets/ └── temp/ // 临时 wav 存放目录把录音和播放各自封装成独立类而不是全写在MainWindow.xaml.cs里是为了后面做“录音列表”“多段回放”“导出”时不用推倒重来。AudioFormat.cs单独抽出来是为了让格式只定义一次。2.4 依赖引入方式如果项目用的是 PackageReference 风格.csproj里加一行ItemGroup PackageReference IncludeNAudio Version2.2.1 / /ItemGroup如果用的是旧式 packages.config就在 NuGet 管理器里搜 NAudio 安装。版本号不必纠结2.x 系列 API 稳定差异主要在 WASAPI 的一些扩展上这个项目用不到。安装完之后代码文件顶部using NAudio.Wave;就能访问核心类。3. 录音链路拆解从麦克风到 wav 文件的完整代码3.1 统一音频格式定义先看AudioFormat.cs这是整条链路的地基using NAudio.Wave; namespace AudioDemo.Audio { public static class AudioFormat { // 16kHz 采样率16 位深度单声道 // 语音场景足够文件体积小兼容性好 public static readonly WaveFormat Default new WaveFormat(16000, 16, 1); } }WaveFormat的三个参数分别是采样率、位深、声道数。16000Hz 是语音识别的常用采样率16 位是标准 PCM 位深单声道能省一半空间。如果做音乐录制可以改成new WaveFormat(44100, 16, 2)。这里的关键是录音和播放必须用同一个实例或至少同一个参数构造否则播放时会出现变速、变调或者直接报“格式不匹配”。3.2 录音类的封装AudioRecorder.cs负责采集和写文件using NAudio.Wave; using System; using System.IO; namespace AudioDemo.Audio { public class AudioRecorder : IDisposable { private WaveInEvent _waveIn; private WaveFileWriter _writer; private string _outputPath; // 录音开始事件外部可订阅更新 UI public event EventHandler RecordingStarted; public event EventHandler RecordingStopped; public void Start(string outputPath) { _outputPath outputPath; // 确保目录存在否则 WaveFileWriter 会抛异常 var dir Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(dir) !Directory.Exists(dir)) Directory.CreateDirectory(dir); _waveIn new WaveInEvent { WaveFormat AudioFormat.Default, BufferMilliseconds 50 // 缓冲区 50ms平衡延迟和丢包 }; _writer new WaveFileWriter(outputPath, AudioFormat.Default); // 每次采集到数据就写入文件 _waveIn.DataAvailable OnDataAvailable; _waveIn.RecordingStopped OnRecordingStopped; _waveIn.StartRecording(); RecordingStarted?.Invoke(this, EventArgs.Empty); } private void OnDataAvailable(object sender, WaveInEventArgs e) { // e.Buffer 里是本次采集的原始 PCM 字节 // e.BytesRecorded 是有效字节数 _writer?.Write(e.Buffer, 0, e.BytesRecorded); } private void OnRecordingStopped(object sender, StoppedEventArgs e) { // 停止时先关 writer再关 waveIn顺序不能反 _writer?.Dispose(); _writer null; _waveIn?.Dispose(); _waveIn null; RecordingStopped?.Invoke(this, EventArgs.Empty); } public void Stop() { _waveIn?.StopRecording(); } public void Dispose() { _waveIn?.Dispose(); _writer?.Dispose(); } } }这段代码有几个关键点。BufferMilliseconds设成 50意思是每 50 毫秒触发一次DataAvailable。设太小会频繁触发事件UI 线程压力大设太大则录音延迟明显而且一旦程序崩溃丢失的音频更多。50 到 100 之间是比较稳妥的范围。OnDataAvailable里只做一件事把缓冲区字节写进文件。不要在这里做格式转换、不要更新 UI、不要加锁否则会阻塞采集线程导致丢帧。停止时的释放顺序也很重要——先释放 writer 把 wav 头补完整再释放 waveIn反过来会写出一个头部损坏的文件这就是很多人“录完没声”的直接原因。3.3 在 MainWindow 里调用录音private AudioRecorder _recorder; private void BtnRecord_Click(object sender, RoutedEventArgs e) { if (_recorder null) { _recorder new AudioRecorder(); _recorder.RecordingStopped (s, args) { // 回到 UI 线程更新按钮状态 Dispatcher.Invoke(() { BtnRecord.Content 开始录音; TxtStatus.Text 录音已保存; }); }; var path Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Assets, temp, record.wav); _recorder.Start(path); BtnRecord.Content 停止录音; TxtStatus.Text 正在录音...; } else { _recorder.Stop(); _recorder.Dispose(); _recorder null; } }这里用Dispatcher.Invoke回到 UI 线程是因为RecordingStopped是在采集线程上触发的直接改控件属性会抛跨线程异常。文件名固定成record.wav只是演示实际项目里应该用时间戳或者 GUID 生成唯一文件名否则第二次录音会覆盖第一次。4. 播放链路拆解WaveOutEvent 的用法与流管理4.1 播放类的封装AudioPlayer.cs比录音简单但流的生命周期同样要小心using NAudio.Wave; using System; namespace AudioDemo.Audio { public class AudioPlayer : IDisposable { private WaveOutEvent _waveOut; private AudioFileReader _reader; public event EventHandler PlaybackStopped; public void Play(string filePath) { // 如果正在播放先停掉旧的 Stop(); _reader new AudioFileReader(filePath); _waveOut new WaveOutEvent(); _waveOut.Init(_reader); _waveOut.PlaybackStopped (s, e) { PlaybackStopped?.Invoke(this, EventArgs.Empty); }; _waveOut.Play(); } public void Stop() { _waveOut?.Stop(); _waveOut?.Dispose(); _waveOut null; _reader?.Dispose(); _reader null; } public void Dispose() { Stop(); } } }AudioFileReader会自动识别 wav 的格式不需要手动传WaveFormat。WaveOutEvent和WaveInEvent一样是事件驱动的不依赖窗口消息循环在 WPF 里比老的WaveOut更稳。PlaybackStopped事件在播放结束或手动停止时都会触发可以用来恢复按钮状态。注意Stop()里先停_waveOut再释放_reader顺序反了会在播放线程还持有 reader 的时候把它释放掉直接抛ObjectDisposedException。4.2 播放时的常见异常与处理播放最容易出的问题是文件被占用。如果录音刚停止WaveFileWriter还没完全释放文件句柄立刻去播放就会报“文件正由另一进程使用”。解决办法是在录音停止事件里加一个短暂的延迟或者用FileShare.Read打开文件流再交给AudioFileReader。另一个问题是播放到一半用户点了停止此时PlaybackStopped会触发如果事件处理里又去调Stop()会形成递归。我一般会在Stop()里加一个_isStopping标志位避免重入。4.3 录音与播放的格式一致性验证如果录音用 16kHz 单声道播放时AudioFileReader读出来的格式就是 16kHz 单声道WaveOutEvent会自动适配声卡。但如果录音文件被外部工具改过格式播放时可能变速。一个简单的验证方法是在播放前打印_reader.WaveFormat的采样率和声道数和AudioFormat.Default对比。不一致就说明文件被改过需要重采样或者提示用户。NAudio 提供了MediaFoundationResampler可以做重采样但那是另一个话题了。5. 避坑与常见问题排查录音播放链路上的五个血泪教训5.1 录出来的文件是杂音或无法播放现象录音按钮点下去文件生成了但播放器打开是刺耳杂音或者直接提示文件损坏。原因通常是WaveFileWriter没有正确释放wav 头里的数据长度字段没被回填。解决确保Stop()里先调_writer.Dispose()再调_waveIn.Dispose()。如果程序异常退出可以在下次启动时扫描临时目录把没有正确关闭的文件标记为损坏并删除。5.2 播放时抛“没有可用的音频设备”现象在部分机器上点播放直接异常提示没有音频输出设备。原因可能是远程桌面会话或者没有声卡的服务器环境。解决在Play()之前检查WaveOut.DeviceCount如果为 0 就提示用户“当前环境没有音频输出设备”而不是让异常冒到 UI 层。录音端同理检查WaveIn.DeviceCount。5.3 录音延迟越来越大现象录了十分钟后播放时发现声音和画面不同步或者录音文件比实际时间长。原因通常是DataAvailable里做了耗时操作导致采集缓冲区堆积。解决DataAvailable里只做写入不做任何 UI 更新、日志记录或格式转换。如果确实需要实时处理把数据丢到一个ConcurrentQueue里用独立线程消费。5.4 第二次录音覆盖第一次现象录了两段发现只有最后一段。原因输出文件名写死了。解决用DateTime.Now.ToString(yyyyMMdd_HHmmss)或者Guid.NewGuid()生成唯一文件名并在界面上维护一个录音列表让用户可以选择播放哪一段。5.5 播放时 UI 卡死现象点播放后界面无响应直到播放结束才恢复。原因在 UI 线程上同步等待播放完成。解决WaveOutEvent.Play()本身是异步的不会阻塞但如果代码里写了while (_waveOut.PlaybackState PlaybackState.Playing) { }这种轮询就会卡死。正确做法是订阅PlaybackStopped事件在事件里更新 UI。6. 二次开发改造从单段录音到录音列表与导出这份资源跑通之后最自然的改造方向是把“录一段、播一段”扩展成“录多段、列表管理、批量导出”。我一般会先加一个AudioClip模型public class AudioClip { public string FilePath { get; set; } public DateTime CreatedAt { get; set; } public TimeSpan Duration { get; set; } public string DisplayName ${CreatedAt:HH:mm:ss} ({Duration.TotalSeconds:F1}s); }然后在MainWindow里用一个ObservableCollectionAudioClip绑定到ListBox。每次录音停止后用AudioFileReader读一下文件拿到TotalTime填进Duration再插入集合。播放时从ListBox.SelectedItem取FilePath传给AudioPlayer.Play()。这样界面和逻辑就解耦了后面加删除、重命名、导出都是在这个集合上操作。导出功能可以用SaveFileDialog让用户选目标路径然后File.Copy过去。如果要做格式转换比如导出成 mp3NAudio 本身不带编码器需要引入NAudio.Lame这样的扩展包用LameMP3FileWriter替代WaveFileWriter。这一步我踩过的坑是Lame 编码器对采样率有要求16kHz 单声道可以直接编但如果是 8kHz 或者立体声需要先重采样否则出来的 mp3 会变调。所以改造时最好把格式转换单独抽一个方法输入输出格式都显式声明不要依赖默认值。还有一个容易忽略的点是临时文件清理。录音文件默认放在Assets/temp下程序退出时如果不清理用久了会占满磁盘。我一般会在App.xaml.cs的OnExit里遍历临时目录删除超过一定天数或者没有被任何AudioClip引用的文件。删除前先检查文件是否被占用用File.Open带FileShare.None试一下能打开才删。从那以后我每次做音频相关的 WPF 功能都会先把格式定义、缓冲区大小、释放顺序这三件事写在便签上贴在显示器边跑通之后再动 UI。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑